diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 1a66918b8e4..60a5f31b9a0 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -100,7 +100,7 @@ typedef TrackProducer = MoqTrackProducer; /// A subscriber-requested track not yet accepted: accept it for a [TrackProducer] or abort it. typedef TrackRequest = MoqTrackRequest; -/// A stream of uncached group requests for one track, for serving fetches on demand. +/// A stream of the fills readers of one track need: runs of groups the cache lacks. typedef TrackDynamic = MoqTrackDynamic; /// A watch-only handle to whether a published track has subscribers; holding it keeps nothing open. @@ -109,8 +109,8 @@ typedef TrackDemand = MoqTrackDemand; /// The read side of a raw track: yields groups in sequence order, skipping ahead if it falls behind. typedef TrackConsumer = MoqTrackConsumer; -/// A request to produce one uncached group for a fetch consumer. -typedef GroupRequest = MoqGroupRequest; +/// A run of groups readers miss: accept each group you have and drop the rest. +typedef Fill = MoqFill; /// The write side of a single group: append frames to it. typedef GroupProducer = MoqGroupProducer; diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart index 0738ec44e14..4f08fc96a88 100644 --- a/dart/moq/lib/src/durations.dart +++ b/dart/moq/lib/src/durations.dart @@ -35,11 +35,25 @@ extension BackoffDuration on MoqBackoff { /// Duration views over the subscription knobs. extension SubscriptionDuration on MoqSubscription { - /// Upper bound on buffering before a stalled group is skipped, or null for the default. + /// Upper bound on buffering before a stalled group is skipped, or null for the + /// publisher's max age alone. Duration? get maxDelay => - maxDelayUs == null ? null : Duration(microseconds: maxDelayUs!); + maxDelayUs == null ? null : _saturatingMicros(maxDelayUs!); } +/// Duration views over a fill. +extension FillDuration on MoqFill { + /// The most tolerant age budget among its readers, or null for the publisher's max age alone. + Duration? get maxDelay { + final us = maxDelayUs(); + return us == null ? null : _saturatingMicros(us); + } +} + +/// A u64 past 2^63 - 1 microseconds, such as a peer's delay, reads back negative. +Duration _saturatingMicros(int us) => + Duration(microseconds: us < 0 ? 0x7FFFFFFFFFFFFFFF : us); + /// Duration views over the publisher-side track settings. extension TrackInfoDuration on MoqTrackInfo { /// Maximum age of a non-latest group before the publisher evicts it, or null for the default. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 5a218fd0416..5b03fad9f56 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -772,7 +772,7 @@ class MoqSubscription { final MoqOrder? order; MoqSubscription({ this.priority = 0, - this.maxDelayUs = null, + this.maxDelayUs = 0, this.ranges = null, this.order = null, }); @@ -12395,34 +12395,40 @@ class FfiConverterMoqBroadcastProducer { } } -abstract class MoqGroupProducerInterface { - void abort({required int errorCode}); - MoqGroupConsumer consume(); - void finish(); - int sequence(); - void writeFrame({required MoqFrame frame}); +abstract class MoqFillInterface { + MoqGroupProducer accept({required int sequence}); + void dropGroups({required int start, required int end}); + int endGroup(); + bool isUsed(); + bool isWanted({required int sequence}); + int? maxDelayUs(); + int? next(); + MoqOrder order(); + int priority(); + int startFrame(); + int startGroup(); + UniffiRustFuture unused(); + UniffiRustFuture unwanted({required int sequence}); } -final _MoqGroupProducerFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(ptr, status)); +final _MoqFillFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqfill(ptr, status)); }); -class MoqGroupProducer implements MoqGroupProducerInterface { +class MoqFill implements MoqFillInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqGroupProducer._(this._ptr) { - _MoqGroupProducerFinalizer.attach(this, _ptr, detach: this); + MoqFill._(this._ptr) { + _MoqFillFinalizer.attach(this, _ptr, detach: this); } - factory MoqGroupProducer.lift(Pointer ptr) { - return MoqGroupProducer._(ptr); + factory MoqFill.lift(Pointer ptr) { + return MoqFill._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqgroupproducer(_ptr, status), - ); + return rustCall((status) => uniffi_moq_ffi_fn_clone_moqfill(_ptr, status)); } void dispose() { @@ -12430,43 +12436,110 @@ class MoqGroupProducer implements MoqGroupProducerInterface { return; } _isDisposed = true; - _MoqGroupProducerFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(_ptr, status)); + _MoqFillFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqfill(_ptr, status)); } - void abort({required int errorCode}) { + MoqGroupProducer accept({required int sequence}) { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_accept( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + status, + ), + FfiConverterMoqGroupProducer.lift, + moqExceptionErrorHandler, + ); + } + + void dropGroups({required int start, required int end}) { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_abort( + uniffi_moq_ffi_fn_method_moqfill_drop_groups( uniffiClonePointer(), - FfiConverterUInt16.lower(errorCode), + FfiConverterUInt64.lower(start), + FfiConverterUInt64.lower(end), status, ); - }, moqExceptionErrorHandler); + }, null); } - MoqGroupConsumer consume() { + int endGroup() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_consume( + (status) => uniffi_moq_ffi_fn_method_moqfill_end_group( uniffiClonePointer(), status, ), - FfiConverterMoqGroupConsumer.lift, - moqExceptionErrorHandler, + FfiConverterUInt64.lift, + null, ); } - void finish() { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_finish( + bool isUsed() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_is_used( uniffiClonePointer(), status, - ); - }, moqExceptionErrorHandler); + ), + FfiConverterBool.lift, + null, + ); } - int sequence() { + bool isWanted({required int sequence}) { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( + (status) => uniffi_moq_ffi_fn_method_moqfill_is_wanted( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + status, + ), + FfiConverterBool.lift, + null, + ); + } + + int? maxDelayUs() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_max_delay_us( + uniffiClonePointer(), + status, + ), + FfiConverterOptionalUInt64.lift, + null, + ); + } + + int? next() { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqfill_next(uniffiClonePointer(), status), + FfiConverterOptionalUInt64.lift, + null, + ); + } + + MoqOrder order() { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqfill_order(uniffiClonePointer(), status), + FfiConverterMoqOrder.lift, + null, + ); + } + + int priority() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_priority( + uniffiClonePointer(), + status, + ), + FfiConverterInt8.lift, + null, + ); + } + + int startFrame() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_start_frame( uniffiClonePointer(), status, ), @@ -12475,69 +12548,98 @@ class MoqGroupProducer implements MoqGroupProducerInterface { ); } - void writeFrame({required MoqFrame frame}) { - return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( + int startGroup() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqfill_start_group( uniffiClonePointer(), - FfiConverterMoqFrame.lower(frame), status, - ); - }, moqExceptionErrorHandler); + ), + FfiConverterUInt64.lift, + null, + ); + } + + UniffiRustFuture unused() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqfill_unused(uniffiClonePointer()), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_cancel_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); + } + + UniffiRustFuture unwanted({required int sequence}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqfill_unwanted( + uniffiClonePointer(), + FfiConverterUInt64.lower(sequence), + ), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_cancel_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); } } -class FfiConverterMoqGroupProducer { - static MoqGroupProducer lift(Pointer ptr) { - return MoqGroupProducer.lift(ptr); +class FfiConverterMoqFill { + static MoqFill lift(Pointer ptr) { + return MoqFill.lift(ptr); } - static Pointer lower(MoqGroupProducer value) { + static Pointer lower(MoqFill value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqGroupProducer value) { + static int allocationSize(MoqFill value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqGroupProducer.lift(pointer), 8); + return LiftRetVal(MoqFill.lift(pointer), 8); } - static int write(MoqGroupProducer value, Uint8List buf) { + static int write(MoqFill value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; } } -abstract class MoqGroupRequestInterface { +abstract class MoqGroupProducerInterface { void abort({required int errorCode}); - MoqGroupProducer accept(); - int priority(); + MoqGroupConsumer consume(); + void finish(); int sequence(); + void writeFrame({required MoqFrame frame}); } -final _MoqGroupRequestFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqgrouprequest(ptr, status)); +final _MoqGroupProducerFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(ptr, status)); }); -class MoqGroupRequest implements MoqGroupRequestInterface { +class MoqGroupProducer implements MoqGroupProducerInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqGroupRequest._(this._ptr) { - _MoqGroupRequestFinalizer.attach(this, _ptr, detach: this); + MoqGroupProducer._(this._ptr) { + _MoqGroupProducerFinalizer.attach(this, _ptr, detach: this); } - factory MoqGroupRequest.lift(Pointer ptr) { - return MoqGroupRequest._(ptr); + factory MoqGroupProducer.lift(Pointer ptr) { + return MoqGroupProducer._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqgrouprequest(_ptr, status), + (status) => uniffi_moq_ffi_fn_clone_moqgroupproducer(_ptr, status), ); } @@ -12546,13 +12648,13 @@ class MoqGroupRequest implements MoqGroupRequestInterface { return; } _isDisposed = true; - _MoqGroupRequestFinalizer.detach(this); - rustCall((status) => uniffi_moq_ffi_fn_free_moqgrouprequest(_ptr, status)); + _MoqGroupProducerFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqgroupproducer(_ptr, status)); } void abort({required int errorCode}) { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqgrouprequest_abort( + uniffi_moq_ffi_fn_method_moqgroupproducer_abort( uniffiClonePointer(), FfiConverterUInt16.lower(errorCode), status, @@ -12560,31 +12662,29 @@ class MoqGroupRequest implements MoqGroupRequestInterface { }, moqExceptionErrorHandler); } - MoqGroupProducer accept() { + MoqGroupConsumer consume() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_accept( + (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_consume( uniffiClonePointer(), status, ), - FfiConverterMoqGroupProducer.lift, + FfiConverterMoqGroupConsumer.lift, moqExceptionErrorHandler, ); } - int priority() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_priority( + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqgroupproducer_finish( uniffiClonePointer(), status, - ), - FfiConverterInt8.lift, - null, - ); + ); + }, moqExceptionErrorHandler); } int sequence() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqgrouprequest_sequence( + (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( uniffiClonePointer(), status, ), @@ -12592,28 +12692,38 @@ class MoqGroupRequest implements MoqGroupRequestInterface { null, ); } + + void writeFrame({required MoqFrame frame}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( + uniffiClonePointer(), + FfiConverterMoqFrame.lower(frame), + status, + ); + }, moqExceptionErrorHandler); + } } -class FfiConverterMoqGroupRequest { - static MoqGroupRequest lift(Pointer ptr) { - return MoqGroupRequest.lift(ptr); +class FfiConverterMoqGroupProducer { + static MoqGroupProducer lift(Pointer ptr) { + return MoqGroupProducer.lift(ptr); } - static Pointer lower(MoqGroupRequest value) { + static Pointer lower(MoqGroupProducer value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqGroupRequest value) { + static int allocationSize(MoqGroupProducer value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqGroupRequest.lift(pointer), 8); + return LiftRetVal(MoqGroupProducer.lift(pointer), 8); } - static int write(MoqGroupRequest value, Uint8List buf) { + static int write(MoqGroupProducer value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; @@ -12622,7 +12732,7 @@ class FfiConverterMoqGroupRequest { abstract class MoqTrackDynamicInterface { void cancel(); - UniffiRustFuture requestedGroup(); + UniffiRustFuture requestedFill(); } final _MoqTrackDynamicFinalizer = Finalizer>((ptr) { @@ -12665,16 +12775,16 @@ class MoqTrackDynamic implements MoqTrackDynamicInterface { }, null); } - UniffiRustFuture requestedGroup() { + UniffiRustFuture requestedFill() { return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_group( + () => uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_fill( uniffiClonePointer(), ), ffi_moq_ffi_rust_future_poll_u64, ffi_moq_ffi_rust_future_complete_u64, ffi_moq_ffi_rust_future_cancel_u64, ffi_moq_ffi_rust_future_free_u64, - (ptr) => FfiConverterMoqGroupRequest.lift(Pointer.fromAddress(ptr)), + (ptr) => FfiConverterMoqFill.lift(Pointer.fromAddress(ptr)), moqExceptionErrorHandler, ); } @@ -18298,7 +18408,7 @@ external void uniffi_moq_ffi_fn_method_moqbroadcastproducer_unannounce( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( +external Pointer uniffi_moq_ffi_fn_clone_moqfill( Pointer handle, Pointer uniffiStatus, ); @@ -18306,32 +18416,83 @@ external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqgroupproducer( +external void uniffi_moq_ffi_fn_free_moqfill( Pointer handle, Pointer uniffiStatus, ); -@Native, Uint16, Pointer)>( +@Native Function(Pointer, Uint64, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_abort( +external Pointer uniffi_moq_ffi_fn_method_moqfill_accept( Pointer ptr, - int error_code, + int sequence, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Uint64, Uint64, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqgroupproducer_consume( +external void uniffi_moq_ffi_fn_method_moqfill_drop_groups( Pointer ptr, + int start, + int end, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( +external int uniffi_moq_ffi_fn_method_moqfill_end_group( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_is_used( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Uint64, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_is_wanted( + Pointer ptr, + int sequence, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_max_delay_us( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_next( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqfill_order( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqfill_priority( Pointer ptr, Pointer uniffiStatus, ); @@ -18339,24 +18500,34 @@ external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( +external int uniffi_moq_ffi_fn_method_moqfill_start_frame( Pointer ptr, Pointer uniffiStatus, ); -@Native, RustBuffer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( +external int uniffi_moq_ffi_fn_method_moqfill_start_group( Pointer ptr, - RustBuffer frame, Pointer uniffiStatus, ); +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqfill_unused( + Pointer ptr, +); + +@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqfill_unwanted( + Pointer ptr, + int sequence, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqgrouprequest( +external Pointer uniffi_moq_ffi_fn_clone_moqgroupproducer( Pointer handle, Pointer uniffiStatus, ); @@ -18364,7 +18535,7 @@ external Pointer uniffi_moq_ffi_fn_clone_moqgrouprequest( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqgrouprequest( +external void uniffi_moq_ffi_fn_free_moqgroupproducer( Pointer handle, Pointer uniffiStatus, ); @@ -18372,7 +18543,7 @@ external void uniffi_moq_ffi_fn_free_moqgrouprequest( @Native, Uint16, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqgrouprequest_abort( +external void uniffi_moq_ffi_fn_method_moqgroupproducer_abort( Pointer ptr, int error_code, Pointer uniffiStatus, @@ -18381,15 +18552,15 @@ external void uniffi_moq_ffi_fn_method_moqgrouprequest_abort( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqgrouprequest_accept( +external Pointer uniffi_moq_ffi_fn_method_moqgroupproducer_consume( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgrouprequest_priority( +external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( Pointer ptr, Pointer uniffiStatus, ); @@ -18397,11 +18568,20 @@ external int uniffi_moq_ffi_fn_method_moqgrouprequest_priority( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqgrouprequest_sequence( +external int uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( Pointer ptr, Pointer uniffiStatus, ); +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame( + Pointer ptr, + RustBuffer frame, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18427,7 +18607,7 @@ external void uniffi_moq_ffi_fn_method_moqtrackdynamic_cancel( ); @Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_group( +external Pointer uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_fill( Pointer ptr, ); @@ -19838,37 +20018,64 @@ uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_track(); external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_abort(); +external int uniffi_moq_ffi_checksum_method_moqfill_accept(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_consume(); +external int uniffi_moq_ffi_checksum_method_moqfill_drop_groups(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_finish(); +external int uniffi_moq_ffi_checksum_method_moqfill_end_group(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence(); +external int uniffi_moq_ffi_checksum_method_moqfill_is_used(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame(); +external int uniffi_moq_ffi_checksum_method_moqfill_is_wanted(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_max_delay_us(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_next(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_order(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_abort(); +external int uniffi_moq_ffi_checksum_method_moqfill_priority(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_accept(); +external int uniffi_moq_ffi_checksum_method_moqfill_start_frame(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_priority(); +external int uniffi_moq_ffi_checksum_method_moqfill_start_group(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence(); +external int uniffi_moq_ffi_checksum_method_moqfill_unused(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqfill_unwanted(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_abort(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_consume(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_finish(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_group(); +external int uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_fill(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackproducer_abort(); @@ -20547,38 +20754,65 @@ void _checkApiChecksums() { 49647) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { + if (uniffi_moq_ffi_checksum_method_moqfill_accept() != 63990) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_consume() != 53274) { + if (uniffi_moq_ffi_checksum_method_moqfill_drop_groups() != 7289) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_finish() != 61241) { + if (uniffi_moq_ffi_checksum_method_moqfill_end_group() != 42504) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { + if (uniffi_moq_ffi_checksum_method_moqfill_is_used() != 6429) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 42053) { + if (uniffi_moq_ffi_checksum_method_moqfill_is_wanted() != 47156) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_max_delay_us() != 40340) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_next() != 7725) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_order() != 32232) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_priority() != 23036) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_start_frame() != 29713) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_start_group() != 8622) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_unused() != 3173) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqfill_unwanted() != 13637) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_abort() != 59787) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_abort() != 26970) { + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_consume() != 53274) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_accept() != 48242) { + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_finish() != 61241) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_priority() != 15373) { + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence() != 29523) { + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 42053) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel() != 57913) { + if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel() != 15841) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_group() != - 63983) { + if (uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_fill() != + 22958) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_abort() != 37537) { diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index df852c3d62b..3ff39936cd8 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -251,7 +251,7 @@ Each subscription carries the knobs that decide behavior under congestion: | --- | --- | | **Priority** (-128..127, default 0) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. The publisher's track priority breaks ties between subscriptions at one priority. From a native Rust publisher over QUIC or WebTransport (iroh excepted), subscriptions at the same priority and publisher priority split bandwidth evenly by bytes, streams and datagrams alike, however many groups each has queued. A browser publisher and other transports without send groups are not covered. | | **Order** | Which group to send first when several are pending. Newest first for live (the default), oldest first for catch-up. Needs moq-lite 07 or moq-transport; older moq-lite always sends newest first. | -| **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. | +| **Max delay** | How long a non-latest group stays worth delivering before it is skipped. Zero means "live edge only", and raising it is also what asks for history. Unset leaves the publisher's retention window alone to bound it. | | **Ranges** | Which groups to ask for: from a group, a number of groups back from the latest, or the group containing a presentation time, optionally up to an end. The default asks for everything max delay allows. Max delay still caps every range. | On moq-lite 05 and later, a publisher explicitly reports groups that will not arrive. @@ -264,7 +264,13 @@ Ranges are both a request and a filter. A subscriber reads only the groups its own ranges cover, resolved again whenever it replaces them, even when another subscriber of the same track asked for more. Widening them, including lowering the start, also serves the cached groups they newly cover, never one already -served. A relay merges its subscribers' ranges into one upstream request, which +served. A group they cover below the live feed that the cache lacks (evicted +before the subscriber first looked for it, skipped, or below where the feed +starts) is asked of the track's dynamic handler +as a fill, which a relay serves by fetching upstream one group at a time, skipping +a group once every subscriber that asked for it narrows past it; with +no handler it is reported as dropped once the feed stops waiting for it. One +evicted after the subscriber saw it cached, but before it read it, is skipped. A relay merges its subscribers' ranges into one upstream request, which today's wire versions carry as the single span covering them. A relative or time anchor resolves on the hop that publishes the track, and over moq-lite-07, which reports the live edge and media time and asks upstream for every group the budget diff --git a/go/wrapper/doc.go b/go/wrapper/doc.go index 8d1f9ca6bcc..ad9b446e490 100644 --- a/go/wrapper/doc.go +++ b/go/wrapper/doc.go @@ -25,7 +25,7 @@ // // What a cancel tears down depends on the call. A one-shot call (a subscribe, a // fetch, RequestBroadcast, Resolve, a TrackDemand's WantedChanged, a -// Server.Accept) aborts on its own and leaves the +// Fill's Unused, Server.Accept) aborts on its own and leaves the // object it was made on usable, so the same // broadcast, producer, or server takes the next call. A stream read (any Next, // RecvGroup, ReadFrame, or the iterators over them) instead cancels the stream diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index a0e858d367a..54ce524d060 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -602,18 +602,24 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { result <- fetchResult{group: group, err: err} }() - request, err := dynamic.RequestedGroup(ctx) + fill, err := dynamic.RequestedFill(ctx) if err != nil { t.Fatal(err) } - if request.Sequence() != 7 || request.Priority() != 11 { - t.Fatalf("unexpected request: sequence=%d priority=%d", request.Sequence(), request.Priority()) + if fill.StartGroup() != 7 || fill.EndGroup() != 8 || fill.Priority() != 11 { + t.Fatalf("unexpected fill: start=%d end=%d priority=%d", fill.StartGroup(), fill.EndGroup(), fill.Priority()) } - produced, err := request.Accept() + if next := fill.Next(); next == nil || *next != 7 || !fill.IsUsed() { + t.Fatalf("unexpected fill: next=%v used=%v", next, fill.IsUsed()) + } + if !fill.IsWanted(7) || fill.IsWanted(8) { + t.Fatalf("unexpected wants: 7=%v 8=%v", fill.IsWanted(7), fill.IsWanted(8)) + } + produced, err := fill.Accept(7) if err != nil { t.Fatal(err) } - if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: ts(time.Duration(request.Sequence()*20_000) * time.Microsecond)}); err != nil { + if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: ts(7 * 20_000 * time.Microsecond)}); err != nil { t.Fatal(err) } if err := produced.Finish(); err != nil { diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 7add3e867f6..a347ed99b4e 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -351,55 +351,110 @@ func (g *GroupProducer) Abort(errorCode uint16) error { return g.inner.Abort(errorCode) } -// TrackDynamic yields uncached groups requested by fetch consumers. +// TrackDynamic yields the fills readers of one track need: runs of groups the cache lacks. type TrackDynamic struct { inner *ffi.MoqTrackDynamic } -// RequestedGroup waits for the next uncached group request. -func (d *TrackDynamic) RequestedGroup(ctx context.Context) (*GroupRequest, error) { - inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedGroup) +// RequestedFill waits for the next fill. +func (d *TrackDynamic) RequestedFill(ctx context.Context) (*Fill, error) { + inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedFill) if err != nil { return nil, err } - return &GroupRequest{inner: inner}, nil + return &Fill{inner: inner}, nil } -// All ranges over uncached group requests until the dynamic source ends. -func (d *TrackDynamic) All(ctx context.Context) iter.Seq2[*GroupRequest, error] { - return bridge.Seq(ctx, d.RequestedGroup) +// All ranges over fills until the dynamic source ends. +func (d *TrackDynamic) All(ctx context.Context) iter.Seq2[*Fill, error] { + return bridge.Seq(ctx, d.RequestedFill) } -// Cancel stops current and future requested-group waits. +// Cancel stops current and future requested-fill waits. func (d *TrackDynamic) Cancel() { d.inner.Cancel() } -// GroupRequest requests one uncached group from a track producer. -type GroupRequest struct { - inner *ffi.MoqGroupRequest +// Fill is a run of groups readers miss, for a TrackDynamic to serve. +// +// Accept each group you have and DropGroups the rest; Next names what is still +// owed, in the fill's order. Releasing the fill names whatever is left as dropped +// while a reader still wants it; once nobody does, it is withdrawn, so stop +// serving it when Unused returns. Stop serving a single group when Unwanted +// returns for it, and move on to Next. +type Fill struct { + inner *ffi.MoqFill +} + +// StartGroup is the first group to fill. +func (f *Fill) StartGroup() uint64 { + return f.inner.StartGroup() +} + +// StartFrame is the frame within the first group to start from. +func (f *Fill) StartFrame() uint64 { + return f.inner.StartFrame() +} + +// EndGroup is the exclusive end group. +func (f *Fill) EndGroup() uint64 { + return f.inner.EndGroup() +} + +// Priority is the most urgent delivery priority among the readers waiting on it. +func (f *Fill) Priority() int8 { + return f.inner.Priority() +} + +// MaxDelay is the most tolerant age budget among its readers, or nil for the +// publisher's max age alone. +func (f *Fill) MaxDelay() *time.Duration { + return optFromMicros(f.inner.MaxDelayUs()) } -// Sequence is the requested group sequence within the track. -func (r *GroupRequest) Sequence() uint64 { - return r.inner.Sequence() +// Order is which end to fill first. +func (f *Fill) Order() Order { + return f.inner.Order() } -// Priority is the consumer's delivery priority for this fetch. -func (r *GroupRequest) Priority() int8 { - return r.inner.Priority() +// Next is the next group still owed in the fill's order, or nil once every group is answered. +func (f *Fill) Next() *uint64 { + return f.inner.Next() } -// Accept accepts the request and returns a producer for the group. -func (r *GroupRequest) Accept() (*GroupProducer, error) { - inner, err := r.inner.Accept() +// Accept inserts group sequence and returns a producer for it. The first group +// starts at StartFrame, so its frames keep their indices. +func (f *Fill) Accept(sequence uint64) (*GroupProducer, error) { + inner, err := f.inner.Accept(sequence) if err != nil { return nil, err } return &GroupProducer{inner: inner}, nil } -// Abort rejects the fetch with an application error code. -func (r *GroupRequest) Abort(errorCode uint16) error { - return r.inner.Abort(errorCode) +// DropGroups names groups start up to the exclusive end as never to be delivered. +func (f *Fill) DropGroups(start, end uint64) { + f.inner.DropGroups(start, end) +} + +// IsUsed reports whether a reader still waits on this fill. +func (f *Fill) IsUsed() bool { + return f.inner.IsUsed() +} + +// Unused blocks until no reader waits on this fill. +func (f *Fill) Unused(ctx context.Context) error { + return f.inner.Unused(ctx) +} + +// IsWanted reports whether a reader still waits for group sequence from this fill: +// false once every reader took it or narrowed past it, or nobody waits on the fill. +func (f *Fill) IsWanted(sequence uint64) bool { + return f.inner.IsWanted(sequence) +} + +// Unwanted blocks until no reader waits for group sequence from this fill, so stop +// serving it. +func (f *Fill) Unwanted(ctx context.Context, sequence uint64) error { + return f.inner.Unwanted(ctx, sequence) } diff --git a/go/wrapper/records.go b/go/wrapper/records.go index 43923f3138d..1ca40ff5a36 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -1,6 +1,7 @@ package moq import ( + "math" "time" ffi "moq.dev/moq-ffi/moq" @@ -11,7 +12,12 @@ import ( // target language has a duration type. These records mirror the generated ones // with time.Duration fields and convert at the boundary. +// fromMicros saturates past time.Duration's ~292 years, which a peer's delay or +// max age can exceed, rather than wrapping to a negative duration. func fromMicros(us uint64) time.Duration { + if us > uint64(math.MaxInt64/int64(time.Microsecond)) { + return time.Duration(math.MaxInt64) + } return time.Duration(us) * time.Microsecond } @@ -58,10 +64,11 @@ func datagramFromFFI(d *ffi.MoqDatagram) *Datagram { } // Subscription holds subscriber-side delivery preferences: priority, the max delay -// a non-latest group may fall behind before it is skipped (nil for moq-net's default of -// zero, which skips at once; it caps every range), the ranges of the track to deliver (nil for everything MaxDelay -// still allows, while an empty non-nil slice asks for nothing), and the order the -// publisher sends pending groups in (nil for newest first). +// a non-latest group may fall behind before it is skipped (zero skips at once, nil +// leaves the publisher's max age alone to bound it, and it caps every range), the +// ranges of the track to deliver (nil for everything MaxDelay still allows, while an +// empty non-nil slice asks for nothing), and the order the publisher sends pending +// groups in (nil for newest first). type Subscription struct { Priority int8 MaxDelay *time.Duration diff --git a/go/wrapper/records_internal_test.go b/go/wrapper/records_internal_test.go new file mode 100644 index 00000000000..ed4162a166d --- /dev/null +++ b/go/wrapper/records_internal_test.go @@ -0,0 +1,27 @@ +package moq + +import ( + "math" + "testing" + "time" +) + +// A peer's delay or max age can pass time.Duration's ~292 years, which must read as +// the longest duration rather than wrap to a negative one that is already expired. +func TestFromMicrosSaturates(t *testing.T) { + limit := uint64(math.MaxInt64 / int64(time.Microsecond)) + cases := []struct { + us uint64 + want time.Duration + }{ + {us: 1, want: time.Microsecond}, + {us: limit, want: time.Duration(limit) * time.Microsecond}, + {us: limit + 1, want: time.Duration(math.MaxInt64)}, + {us: math.MaxUint64, want: time.Duration(math.MaxInt64)}, + } + for _, c := range cases { + if got := fromMicros(c.us); got != c.want { + t.Errorf("fromMicros(%d) = %v, want %v", c.us, got, c.want) + } + } +} diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index eb38b34963b..5a07eb3dd09 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -81,14 +81,14 @@ typealias BroadcastFinish = uniffi.moq.MoqBroadcastFinish typealias TrackProducer = uniffi.moq.MoqTrackProducer /** A subscriber-requested track not yet accepted: accept it for a [TrackProducer] or abort it. */ typealias TrackRequest = uniffi.moq.MoqTrackRequest -/** A stream of uncached group requests for one track, for serving fetches on demand. */ +/** A stream of the fills readers of one track need: runs of groups the cache lacks. */ typealias TrackDynamic = uniffi.moq.MoqTrackDynamic /** A watch-only handle to whether a published track has subscribers; holding it keeps nothing open. */ typealias TrackDemand = uniffi.moq.MoqTrackDemand /** The read side of a raw track: yields groups in sequence order, skipping ahead if it falls behind. */ typealias TrackConsumer = uniffi.moq.MoqTrackConsumer -/** A request to produce one uncached group for a fetch consumer. */ -typealias GroupRequest = uniffi.moq.MoqGroupRequest +/** A run of groups readers miss: accept each group you have and drop the rest. */ +typealias Fill = uniffi.moq.MoqFill /** The write side of a single group: append frames to it. */ typealias GroupProducer = uniffi.moq.MoqGroupProducer /** The read side of a single group: yields timestamped raw frames. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt index e58584abbe3..72a2df33db1 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt @@ -53,9 +53,16 @@ val QuicConfig.idleTimeout: Duration? val QuicConfig.keepAlive: Duration? get() = keepAliveUs?.toLong()?.microseconds -/** Upper bound on buffering before a stalled group is skipped, or null for the default. */ +/** Upper bound on buffering before a stalled group is skipped, or null for the publisher's max age alone. */ val Subscription.maxDelay: Duration? - get() = maxDelayUs?.toLong()?.microseconds + get() = maxDelayUs?.let(::saturatingMicros) + +/** The most tolerant age budget among a fill's readers, or null for the publisher's max age alone. */ +val Fill.maxDelay: Duration? + get() = maxDelayUs()?.let(::saturatingMicros) + +// A peer's delay can pass Long.MAX_VALUE microseconds, which toLong() would wrap negative. +private fun saturatingMicros(us: ULong): Duration = minOf(us, Long.MAX_VALUE.toULong()).toLong().microseconds /** Maximum age of a non-latest group before the publisher evicts it, or null for the default. */ val TrackInfo.maxAge: Duration? diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt index 0b9000452ef..3795a732399 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt @@ -39,8 +39,8 @@ fun TrackConsumer.datagrams(): Flow = pull({ cancel() }) { recvDatagra /** Stream of tracks requested by subscribers. */ fun BroadcastDynamic.requestedTracks(): Flow = pull({ cancel() }) { requestedTrack() } -/** Stream of uncached group requests for one track. */ -fun TrackDynamic.requestedGroups(): Flow = pull({ cancel() }) { requestedGroup() } +/** Stream of the fills readers of one track need. */ +fun TrackDynamic.requestedFills(): Flow = pull({ cancel() }) { requestedFill() } /** Stream of broadcasts requested by consumers. */ fun OriginDynamic.requestedBroadcasts(): Flow = pull({ cancel() }) { requestedBroadcast() } diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index 90714816d26..2e05799eede 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -66,7 +66,7 @@ asyncio.run(main()) TrackDynamic TrackRequest GroupProducer - GroupRequest + Fill ``` ## Subscribing diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index 97c24aa1f3b..eeefee47eb6 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -34,8 +34,8 @@ BroadcastDynamic, BroadcastFinish, BroadcastProducer, + Fill, GroupProducer, - GroupRequest, TrackDemand, TrackDynamic, TrackProducer, @@ -119,9 +119,9 @@ "ProtocolKind", "QuicConfig", "FetchGroupOptions", + "Fill", "Grant", "GroupConsumer", - "GroupRequest", "GroupProducer", "OriginConsumer", "OriginDynamic", diff --git a/py/moq-rs/moq/_records.py b/py/moq-rs/moq/_records.py index 25375db7fb7..adf88116330 100644 --- a/py/moq-rs/moq/_records.py +++ b/py/moq-rs/moq/_records.py @@ -100,15 +100,15 @@ class Subscription: """Subscriber-side delivery preferences, mirroring moq-net's ``track::Subscription``. ``priority`` is in ``-128..=127``, higher first, and defaults to 0, the midpoint. - ``max_delay`` is how far a non-latest group may fall behind before it is skipped, - ``None`` for moq-net's default of zero, which skips at once; it caps every range. - ``ranges`` are the spans of the track to deliver, ``None`` for everything ``max_delay`` - still allows. ``order`` is which pending group the publisher sends first, ``None`` for - newest first. + ``max_delay`` is how far a non-latest group may fall behind before it is skipped; + zero skips at once, ``None`` leaves the publisher's max age alone to bound it, and it + caps every range. ``ranges`` are the spans of the track to + deliver, ``None`` for everything ``max_delay`` still allows. ``order`` is which pending + group the publisher sends first, ``None`` for newest first. """ priority: int = 0 - max_delay: timedelta | None = None + max_delay: timedelta | None = timedelta(0) ranges: Sequence[MoqRange] | None = None order: MoqOrder | None = None diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 79db88f262f..847886dadb5 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -11,15 +11,16 @@ MoqBroadcastDynamic, MoqBroadcastFinish, MoqBroadcastProducer, + MoqFill, MoqGroupProducer, - MoqGroupRequest, + MoqOrder, MoqTrackDemand, MoqTrackDynamic, MoqTrackProducer, MoqTrackRequest, ) -from ._records import _from_us, _subscription, _track_info +from ._records import _from_us, _opt_from_us, _subscription, _track_info from ._stream import _Stream from .types import ( AnnounceRoute, @@ -192,33 +193,86 @@ def abort(self, error_code: int) -> None: self._inner.abort(error_code) -class GroupRequest: - """A request to produce one uncached group for a fetch consumer.""" +class Fill: + """A run of groups readers miss, for a :class:`TrackDynamic` to serve. - def __init__(self, inner: MoqGroupRequest) -> None: + Call :meth:`accept` for each group you have and :meth:`drop_groups` for the rest; + :meth:`next` names what is still owed, in the fill's order. Dropping the fill names + whatever is left as dropped while a reader still wants it; once nobody does, it is + withdrawn, so stop serving it when :meth:`unused` returns. Stop serving a single group + when :meth:`unwanted` returns for it, and move on to :meth:`next`. + """ + + def __init__(self, inner: MoqFill) -> None: self._inner = inner @property - def sequence(self) -> int: - """The requested group sequence within the track.""" - return self._inner.sequence() + def start_group(self) -> int: + """The first group to fill.""" + return self._inner.start_group() + + @property + def start_frame(self) -> int: + """The frame within the first group to start from.""" + return self._inner.start_frame() + + @property + def end_group(self) -> int: + """The exclusive end group.""" + return self._inner.end_group() @property def priority(self) -> int: - """The consumer's delivery priority for this fetch.""" + """The most urgent delivery priority among the readers waiting on it.""" return self._inner.priority() - def accept(self) -> GroupProducer: - """Accept the request and return a producer for the group.""" - return GroupProducer(self._inner.accept()) + @property + def max_delay(self) -> timedelta | None: + """The most tolerant age budget among its readers, ``None`` for the publisher's max age alone.""" + return _opt_from_us(self._inner.max_delay_us()) - def abort(self, error_code: int) -> None: - """Reject the fetch with an application error code.""" - self._inner.abort(error_code) + @property + def order(self) -> MoqOrder: + """Which end to fill first.""" + return self._inner.order() + + def next(self) -> int | None: + """The next group still owed in the fill's order, ``None`` once every group is answered.""" + return self._inner.next() + + def accept(self, sequence: int) -> GroupProducer: + """Insert group ``sequence`` and return a producer for it. + + The first group starts at :attr:`start_frame`, so its frames keep their indices. + """ + return GroupProducer(self._inner.accept(sequence)) + + def drop_groups(self, start: int, end: int) -> None: + """Name groups ``start`` up to the exclusive ``end`` as never to be delivered.""" + self._inner.drop_groups(start, end) + + def is_used(self) -> bool: + """Whether a reader still waits on this fill.""" + return self._inner.is_used() + + async def unused(self) -> None: + """Wait until no reader waits on this fill.""" + await self._inner.unused() + + def is_wanted(self, sequence: int) -> bool: + """Whether a reader still waits for group ``sequence`` from this fill. + + False once every reader took it or narrowed past it, or nobody waits on the fill. + """ + return self._inner.is_wanted(sequence) + + async def unwanted(self, sequence: int) -> None: + """Wait until no reader waits for group ``sequence`` from this fill, so stop serving it.""" + await self._inner.unwanted(sequence) -class TrackDynamic(_Stream[GroupRequest]): - """Async source of uncached group requests for one track. +class TrackDynamic(_Stream[Fill]): + """Async source of the fills readers of one track need: runs of groups the cache lacks. Usable as an async context manager that cancels on exit. """ @@ -226,15 +280,15 @@ class TrackDynamic(_Stream[GroupRequest]): def __init__(self, inner: MoqTrackDynamic) -> None: self._inner = inner - async def _next(self) -> GroupRequest: - return await self.requested_group() + async def _next(self) -> Fill: + return await self.requested_fill() - async def requested_group(self) -> GroupRequest: - """Wait for the next uncached group request.""" - return GroupRequest(await self._inner.requested_group()) + async def requested_fill(self) -> Fill: + """Wait for the next fill.""" + return Fill(await self._inner.requested_fill()) def cancel(self) -> None: - """Cancel current and future group request waits.""" + """Cancel current and future fill waits.""" self._inner.cancel() diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index ae0b6a811b2..d22f95453bc 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -102,25 +102,20 @@ def h264_init() -> bytes: return sps + pps -async def test_fetch_abort_is_a_stream_app_code(): +async def test_fetch_of_a_dropped_group_is_not_found(): broadcast = moq.BroadcastProducer() track = broadcast.publish_track("events") dynamic = track.dynamic() consumer = broadcast.consume() async def reject(): - request = await dynamic.requested_group() - request.abort(404) + fill = await dynamic.requested_fill() + fill.drop_groups(5, 6) task = asyncio.create_task(reject()) - with pytest.raises(moq.Error.Protocol) as raised: + with pytest.raises(moq.Error.NotFound): await consumer.fetch_group("events", 5) await task - protocol = moq.protocol_error(raised.value) - assert protocol is not None - assert protocol.scope == moq.ErrorScope.STREAM - assert protocol.code == 64 + 404 - assert protocol.kind == moq.ProtocolKind.APP def test_protocol_error_helper_covers_known_app_and_unknown(): @@ -516,11 +511,15 @@ async def test_fetch_group_and_serve_dynamic_miss(): dynamic = track.dynamic() pending = asyncio.create_task(consumer.fetch_group("events", 7, moq.FetchGroupOptions(priority=11))) - request = await asyncio.wait_for(dynamic.requested_group(), timeout=5.0) - assert request.sequence == 7 - assert request.priority == 11 - - produced = request.accept() + fill = await asyncio.wait_for(dynamic.requested_fill(), timeout=5.0) + assert (fill.start_group, fill.start_frame, fill.end_group) == (7, 0, 8) + assert fill.priority == 11 + assert fill.next() == 7 + assert fill.is_used() + assert fill.is_wanted(7) + assert not fill.is_wanted(8) + + produced = fill.accept(7) produced.write_frame(b"archive", timedelta(microseconds=140_000)) produced.finish() @@ -1670,7 +1669,7 @@ async def assert_cancelled(awaitable) -> None: track = broadcast.publish_track("events") async with track.dynamic() as track_dynamic: pass - await assert_cancelled(track_dynamic.requested_group()) + await assert_cancelled(track_dynamic.requested_fill()) snapshot = moq.json.SnapshotProducer(broadcast, broadcast.publish_track("state")) async with moq.json.SnapshotConsumer(await broadcast.consume().subscribe_track("state")) as snapshot_consumer: diff --git a/quest/m1/api-audit/requests.md b/quest/m1/api-audit/requests.md index fdad1be8405..b63c6d63bc8 100644 --- a/quest/m1/api-audit/requests.md +++ b/quest/m1/api-audit/requests.md @@ -28,4 +28,3 @@ Public API: moq-net, `@moq/net`, moq-ffi and wrappers renamed. Wire: none. ## Related - [Claim request type](/quest/m1/api-audit/claim-request-type.md) - reshapes the origin claim this renames -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - adds `requested_fill` to the track handle this renames diff --git a/quest/m1/archive/consumer.md b/quest/m1/archive/consumer.md index a356218ea04..74ea2e92e8d 100644 --- a/quest/m1/archive/consumer.md +++ b/quest/m1/archive/consumer.md @@ -61,10 +61,10 @@ Guidance: the same way; history-mode callers rely on the start answering with what remains ([History from the retained floor](/quest/m1/archive/history-floor.md)). - Keep a byte-bounded object LRU so neighbouring groups come from one GET. -- When [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) lands, the - `track::Dynamic` handler resolves a `Time(pts)` end by picking the object - from the key listing, then the group containing pts from that object's - group timestamps, whichever quest lands second. +- A `track::Fill` names group positions only, so a `Time(pts)` end the cache + can't resolve never reaches the handler. Resolving one from the key listing + (the object, then the group containing pts from its group timestamps) needs + a fill that carries the anchor. - Move `rs/moq-hls/src/export/archive_tests.rs` onto a consumer broadcast with its archived catalog, deleting the hand-built catalog. - Update `doc/bin/cli.md` and `doc/bin/hls.md` inline with an export, import, diff --git a/quest/m1/c/README.md b/quest/m1/c/README.md index 745c01f003d..79d7c8af441 100644 --- a/quest/m1/c/README.md +++ b/quest/m1/c/README.md @@ -57,11 +57,11 @@ and [Rust identity enforcement](/quest/m1/catalog-identity-rs.md)) Require this line and land their C half on the generated package, so C users break once. Signed priorities already reached the hand-written ABI as `int8_t` in [moq-dev/moq#6142](https://github.com/moq-dev/moq/pull/6142). -[Dynamic subscribe ranges](/quest/m1/subscribe-ranges/dynamic.md) is the -settled exception from its later plan in -[moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123): if generation -has not landed, the frozen C crate splits fills into its existing per-group -requests internally and keeps its ABI, so that quest does not wait here. +Dynamic subscribe ranges ([moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171)) +was the settled exception from its later plan in +[moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123): generation +had not landed, so the frozen C crate splits fills into its existing per-group +requests internally and keeps its ABI. ## Required diff --git a/quest/m1/drop-set.md b/quest/m1/drop-set.md index 6608e9c6285..222fecffd40 100644 --- a/quest/m1/drop-set.md +++ b/quest/m1/drop-set.md @@ -207,7 +207,7 @@ Test on mock time through a relay: one step, whether dropped, skipped by a wider reader, or evicted and judged again. - Per-reader misses are bounded the same way. [Dynamic range - requests](/quest/m1/subscribe-ranges/dynamic.md) adds a third unbounded + requests](https://github.com/moq-dev/moq/pull/6171) adds a third unbounded record: each reader's given-up misses, one per gap, which a reader whose floor never moves cannot forget. Fold them into the cursor's runs, billed to the pool (from diff --git a/quest/m1/fill-updates.md b/quest/m1/fill-updates.md index 87c847dd409..071c865d0e5 100644 --- a/quest/m1/fill-updates.md +++ b/quest/m1/fill-updates.md @@ -7,7 +7,7 @@ taken by a request handler serve under the new values. ## Plan -After [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md), a +After [Dynamic range requests](https://github.com/moq-dev/moq/pull/6171), a `track::Fill` keeps the priority, delay, and order its joins gave it. Once a handler takes it, an update cannot reach it, and a queued one is not refreshed. A reader that switches to descending still has filled groups @@ -15,7 +15,3 @@ served in the old order. Cover queued and taken fills with one mechanism, and test an order switch mid-fill. Public API: possibly a handler-facing update. Wire: none. - -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - introduces the fills diff --git a/quest/m1/resolved-demand.md b/quest/m1/resolved-demand.md index 3fdd101bdaa..a73902a66a5 100644 --- a/quest/m1/resolved-demand.md +++ b/quest/m1/resolved-demand.md @@ -168,10 +168,6 @@ Public API: none. Wire: no encoding change. A publisher now re-offers cached groups it never reported dropped when a SUBSCRIBE_UPDATE widens the max age or the range. -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the range miss fill this reuses, beside #5929's landed re-offer and serving path - ## Related - [Drop set](/quest/m1/drop-set.md) - closes the gap this leaves for groups already reported dropped diff --git a/quest/m1/subscribe-ranges/README.md b/quest/m1/subscribe-ranges/README.md index 77d4b797449..2312f2827ad 100644 --- a/quest/m1/subscribe-ranges/README.md +++ b/quest/m1/subscribe-ranges/README.md @@ -34,9 +34,8 @@ relay bounds a sparse FETCH only if the model can request ranges: publisher delivers what it has and fills the rest as upstream answers. - **Model.** `Subscription` gains `ranges` beside its `order`, mirroring the wire; `request_groups` as a separate API was rejected. A publisher's - `track::Dynamic` receives range requests for misses, replacing per-group - `requested_group`, and `fetch_group` becomes a one-range subscription or is - removed. This breaks published moq-net APIs. + `track::Dynamic` receives a `track::Fill` per run of misses (landed), and + `fetch_group` becomes a one-range subscription or is removed. This breaks published moq-net APIs. - **moq-transport.** Some deployments use non-contiguous group numbers, so a relay never probes upstream one group at a time. Upstream, each run of locally missing groups becomes one standalone range FETCH, capped below the @@ -95,7 +94,6 @@ every sequence delivered or dropped. ## Required -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `Dynamic` fills misses by range, replacing `requested_group` - [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - `fetch_group` becomes a one-range subscription that ends once its range is accounted for - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) - the lite-07 wire, Rust publisher and subscriber, and the draft - [moq-transport ranges](/quest/m1/subscribe-ranges/ietf.md) - range FETCH upstream per missing run, and non-blocking FETCH served downstream diff --git a/quest/m1/subscribe-ranges/dynamic.md b/quest/m1/subscribe-ranges/dynamic.md deleted file mode 100644 index c7437f0ac62..00000000000 --- a/quest/m1/subscribe-ranges/dynamic.md +++ /dev/null @@ -1,99 +0,0 @@ -# [L] Dynamic range requests - -## Goal - -A publisher's `track::Dynamic` receives range requests for misses instead of -one request per group, and every sequence in a subscription's range either -arrives or is named as dropped, so a reader never waits on a miss. -`requested_group` is replaced by `requested_fill`, not kept beside it, in every -caller and binding (the frozen moq-c ABI excepted, per the moq-c decision -below). Removing `fetch_group` is the follow-up -[fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md). - -## Plan - -A subscriber already reads only its own ranges, re-resolved when they are -replaced. What remains is the miss: one below the cache becomes a range request -on `Dynamic` rather than a group request. A time anchor the cache can't -resolve passes to the handler unresolved in its range request. - -- Replace `requested_group` rather than keep both. Update every caller - (sessions, relay, moq-archive, hls, the ladder) in the same PR, including - the archive consumer's time resolution from its key listing if it has - landed, plus the published surfaces built on it, per the cross-package - table: moq-ffi (`requested_group`), every binding wrapper and its `doc/lib` - page. Until the follow-up lands, a `fetch_group` miss queues a one-group - fill. -- Every sequence in range either arrives or is named as dropped. Share the - accounting with SUBSCRIBE_DROP rather than adding a second one: the cursor's - drop scan already names only sequences inside the reader's own ranges. -- Decided in the 2026-10-10 audit: `max_delay` caps every range, including - historical FETCH replacements, and publisher `max_age` always clamps it. - A FETCH with no reader delay uses publisher max-age alone, not the live - SUBSCRIBE zero-delay default. Sharing a range never relaxes a caller's - budget or lets one expired caller cancel another still-valid reader. - -Decided by the maintainer on 2026-10-20, when the quest was first started -([moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123)): - -- **Split.** This quest replaces `requested_group`; removing `fetch_group` - is [fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md), so each - PR breaks one API once. That half needs head fills for a range starting - mid-group, which build on frame bounds - ([moq-dev/moq#6129](https://github.com/moq-dev/moq/pull/6129)). -- **Handler shape.** `Dynamic::requested_fill` yields a `track::Fill` naming - one bounded run: `start: Position`, exclusive end group, priority, the most - tolerant budget among its readers, and order. Each reader's cursor still - skips what is stale under its own `max_delay`, so sharing never relaxes it. - The handler calls `accept(sequence, info)` for each group it has and - `drop_groups(range)` for the rest. Dropping the `Fill` - names whatever is left as dropped, in the track's existing `drops`, but only - while a reader still wants it: once demand leaves, the fill is withdrawn like - a `group::Request` today and records nothing, so `drops` (durable) never - hides still-fetchable history from a later reader. - Rejected: writing through `Producer::create_group`, since a fetch-only copy - or an archive track has no live producer, and backfill must stay out of the - arrival walk. -- **What a miss is.** A sequence in a reader's window, below its cap, that - the cache doesn't cover and the feed will never deliver: below the feed's - start, or at or below its newest group and not awaited by the arrival - ledger (evicted or skipped). Nothing above that frontier is a miss, since it - arrives live, so a fill is bounded by construction. A relay records the - span a SUBSCRIBE_START skipped past its requested floor as dropped (today it - only settles the tail), or it would FETCH what upstream already gave up. -- **Fetch budget.** `Subscription::max_delay` becomes `Option`: - `None` means publisher max-age alone, the FETCH default above. This breaks - `Subscription` in every binding, and the Impact bullet says how to migrate each affected - package. Rejected (recommended, but overruled): a `Duration::MAX` sentinel - that the wire saturates, which avoids the break but encodes the meaning in a - magic value. -- **Sessions.** A session serves a fill as it serves a group request today: - one FETCH per group, one in flight per fill, newest first for a descending - reader, withdrawing (not dropping) the fill when demand leaves. Once a - fetched group is stale under the fill's budget, the groups older than it - are too (a timed track's starts never rewind), so the fill stops fetching - them; in either order, newer groups still arrive. They are withdrawn, not - dropped, and each reader's cursor names them stale under its own budget, - since a later reader with a wider one may still fetch them. Only staleness - under publisher max-age, which binds every reader, goes in `drops`. - [Lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) turns lite-07 - fills into range subscriptions, and - [moq-transport ranges](/quest/m1/subscribe-ranges/ietf.md) into one FETCH - per run. Until then, one SUBSCRIBE range can cost up to frontier minus floor - serialized upstream FETCHes, each bounded by the fill's budget and stopped - when demand leaves, the same cost per-group requests carry today; accepted - rather than blocking on those quests. -- **moq-c.** Whichever lands first decides it. If the generated C package - ([moq-dev/moq#6052](https://github.com/moq-dev/moq/pull/6052)) has landed, - `poll_requested_group` leaves the hand-written ABI with a migration note - and the fill reaches C through the generated API. Otherwise the frozen crate - splits each fill into its per-group requests internally, keeping its ABI - unchanged, and maps `moq_subscription.max_delay_us` to `Some`: a frozen C - subscriber cannot ask for max-age alone, as today. Not a blocker. -- **Bench.** Fills fan out, so add a fill bench swept over both axes, - subscribers x misses, so a cost that grows with either shows up as a slope - (decided 2026-10-21). - -## Related - -- [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - removes `fetch_group` once fills land diff --git a/quest/m1/subscribe-ranges/fetch-range.md b/quest/m1/subscribe-ranges/fetch-range.md index f4637dbc663..fd2e214ff02 100644 --- a/quest/m1/subscribe-ranges/fetch-range.md +++ b/quest/m1/subscribe-ranges/fetch-range.md @@ -10,8 +10,8 @@ filling over range count and span as separate axes. ## Plan -Split out of [dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) -by the maintainer on 2026-10-20 +Split out of dynamic range requests, which replaced `requested_group` with +`requested_fill`, by the maintainer on 2026-10-20 ([moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123)), so each PR breaks one API once. @@ -36,7 +36,3 @@ breaks one API once. 2026-10-20). Only `fetch_group` requests a head fill today, so the one-range read has to request it instead of letting `Lagged` reach the caller. - Benchmark filling beside `track_range_serving`, which covers serving. - -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `requested_fill` and subscriptions filling their misses diff --git a/quest/m1/subscribe-ranges/ietf.md b/quest/m1/subscribe-ranges/ietf.md index 2ad5580291d..5f374123b7e 100644 --- a/quest/m1/subscribe-ranges/ietf.md +++ b/quest/m1/subscribe-ranges/ietf.md @@ -48,10 +48,6 @@ moq-transport has no time filter, so a downstream `Time(pts)` end is resolved against the relay's cache before it goes upstream as a group range, or refused when the cache cannot resolve it. -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the range requests this answers - ## Related - [Pipelined first FETCH](/quest/m2/pipeline-requests/fetch.md) - sends TRACK_STATUS alongside the FETCH in adjacent code (`ietf/subscriber.rs`, `model/origin.rs`); an independent m2 optimization that adapts to these ranges and does not gate them diff --git a/quest/m1/subscribe-ranges/js.md b/quest/m1/subscribe-ranges/js.md index 691248eb13f..fd8474e0fc5 100644 --- a/quest/m1/subscribe-ranges/js.md +++ b/quest/m1/subscribe-ranges/js.md @@ -10,11 +10,16 @@ publisher serves requested ranges that are no longer in its live cache. ## Plan Mirror the Rust names, including `Time(pts)` ends and their refusal on an untimed track. +Also mirror Rust's optional `Subscription::max_delay` +([moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171)): a `null` +`maxDelay` asks for the publisher's max age alone, dominates when subscriptions +combine, and goes out as the largest delay the wire allows, where today an +omitted one means the live edge only. Decided in the 2026-10-05 audit: the producer half of [JavaScript FETCH](/quest/m1/js-fetch.md) folds in here, so `@moq/net`'s on-demand request surface takes range requests from the start instead of the -per-group `requested_group` shape Rust is replacing. That quest keeps only +per-group `requested_group` shape Rust replaced with `requested_fill`. That quest keeps only IETF FETCH dispatch. Resized from [M] to [L] for it. - Expose an owned request handle rather than a storage callback; dropping a diff --git a/quest/m1/subscribe-ranges/lite.md b/quest/m1/subscribe-ranges/lite.md index a63e592d340..c2fd94d6682 100644 --- a/quest/m1/subscribe-ranges/lite.md +++ b/quest/m1/subscribe-ranges/lite.md @@ -64,10 +64,6 @@ Decided 2026-10-22 (maintainer): SUBSCRIBE_OK carries the resolved start for a relative range start, so a reader's ordered cursor takes its floor from it ([Container reads drop backfill](/quest/m1/read-backfill.md)). -## Required - -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - the misses a relay fills upstream by range - ## Related - [Container reads drop backfill](/quest/m1/read-backfill.md) - floors its ordered cursor on the resolved start diff --git a/quest/m1/unbounded-groups/README.md b/quest/m1/unbounded-groups/README.md index 821bcb51360..2dd87a03c13 100644 --- a/quest/m1/unbounded-groups/README.md +++ b/quest/m1/unbounded-groups/README.md @@ -78,9 +78,7 @@ Decided 2026-10-16 (maintainer interview): draft's 32 MiB interop cap. The [m1 compression migration](/quest/m1/compress/migrate.md) deletes the flate packages. There is no new page. -The refill API is the -[Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) -`track::Dynamic` range request. A handler serves a range by writing into a +The refill API is the `track::Dynamic` fill (`track::Fill`). A handler serves a range by writing into a group producer bounded to that range, the same API as a live write, so an origin can regenerate data from disk or an archive. diff --git a/quest/m1/unbounded-groups/front-eviction.md b/quest/m1/unbounded-groups/front-eviction.md index 78c3f9c6747..01e1b9c7404 100644 --- a/quest/m1/unbounded-groups/front-eviction.md +++ b/quest/m1/unbounded-groups/front-eviction.md @@ -38,8 +38,7 @@ See the [epic](/quest/m1/unbounded-groups/README.md) for the decisions. takes it: hand its first demanded frame to that reader, or backpressure the refill on the reader's cursor. Test a `track::Dynamic` handler that writes more than the pool synchronously, so the reader still progresses. -- Refill goes through - [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md). Over +- Refill goes through a `track::Dynamic` fill. Over moq-transport, a hole that covers whole objects refills; one inside an object is `Evicted`. - Downstream encoders that roll groups by the removed constants (moq-json, @@ -66,7 +65,6 @@ groups. A reader that used to see `GroupTooLarge` now refills or sees - [Group storage benchmark](/quest/m1/group-storage-bench.md) - picks the group storage this builds on - [Segmented frames](/quest/m1/unbounded-groups/segmented-frames.md) - frame storage the cache can account for without whole-frame preallocation -- [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - `track::Dynamic` receives frame-precise range requests for misses ## Related diff --git a/quest/m2/README.md b/quest/m2/README.md index 38ed382503d..8435cc5ca0a 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -74,6 +74,7 @@ lite-07; bitrate enforcement is not part of this release. - [Read-only lookup](/quest/m2/read-only-lookup.md) - a live track lookup stops waking the front and every demand watcher - [Ordered owed wait](/quest/m2/ordered-owed-wait.md) - an ordered reader waits within max_delay for a missing group only while the track still owes it - [lite-05/06 gap judgement](/quest/m2/lite-legacy-gap.md) - a resumed copy on lite-05/06 never reads a descending track's newest group as its start +- [Refill evicted groups](/quest/m2/evicted-refill.md) - a group evicted after a reader judged it but before it took it is refilled, never delivered twice, and guarded against thrash - [Receive pool](/quest/m2/receive-pool.md) - one byte budget per worker covers every connection's receive credit and the cache, billing each frame once - [Rust-backed @moq/net](/quest/m2/rs2ts/README.md) - measure browser baselines, complete WASM bindings, and adopt Rust behind @moq/net before the later JS-only stage - [Front parking](/quest/m2/origin-front-parks.md) - an unroutable request waits on a front instead of re-asking on every route-table move diff --git a/quest/m2/evicted-refill.md b/quest/m2/evicted-refill.md new file mode 100644 index 00000000000..bdd740325ee --- /dev/null +++ b/quest/m2/evicted-refill.md @@ -0,0 +1,36 @@ +# [M] Refill groups evicted before a reader took them + +## Goal + +A reader behind the live edge still gets a group the cache evicted after the +reader judged it but before it took it: the group becomes a miss and is asked +of the track's fills, like one evicted before the reader judged it. A reader +never receives a group it already took twice, and slow readers under cache +pressure cannot make the relay refetch without bound. + +## Plan + +From [moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171), where the +maintainer chose to land fills without this (2026-10-21). Today a reader +judges each sequence once: a group cached when judged and evicted later is +skipped silently. + +- Track evictions per reader. An ordered reader can judge again every + eviction at or above its next sequence. An arrival-order reader delivers + above its floor, so it must tell the groups it already walked past from + the ones still ahead, for example by keeping each evicted group's arrival + index. Bound what the track keeps by the slowest reader, not by the cache. +- Guard against thrash. Under pool pressure a refill evicts another group, + possibly the next one the same slow reader needs. Decide in the PR, with + options and a recommendation, how a reader stops refilling once its + refills only evict what it reads next: give up to the drop cursor, or rely + on its max delay. +- Test a slow ascending reader on a pooled track under bufferbloat: it + receives every group its budget covers, takes none twice, and refills stay + proportional to what it reads. + +Public API: none. Wire: none. + +## Related + +- [Unbounded groups](/quest/m1/unbounded-groups/README.md) - refills the parts of a group the cache sheds under pressure diff --git a/quest/m2/net-wrap/traits.md b/quest/m2/net-wrap/traits.md index 54569884376..c0ce52f7a14 100644 --- a/quest/m2/net-wrap/traits.md +++ b/quest/m2/net-wrap/traits.md @@ -32,8 +32,7 @@ the methods exactly as moq-net has them today. [E2EE undeclared names](/quest/m2/e2ee/undeclared-names.md) needs). A track dynamic's `requested_fill` yields its own `track::Fill`, whose `accept` returns its own group producer and whose `drop_groups` names - the rest dropped - ([dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md)). + the rest dropped. - Pending operations: `track::Consumer::subscribe` resolves to the wrapper's own subscriber, so E2EE decrypts behind it; `Querying` and `Fetching` resolve to the wrapper's logical metadata and groups. diff --git a/rs/moq-archive/src/reader/mod.rs b/rs/moq-archive/src/reader/mod.rs index 414106b4ea9..1b8348fa5b0 100644 --- a/rs/moq-archive/src/reader/mod.rs +++ b/rs/moq-archive/src/reader/mod.rs @@ -38,7 +38,7 @@ use futures::TryStreamExt; use futures::future::BoxFuture; use hang::timeline::{Position, Record}; use moq_json::window; -use moq_net::{Timescale, Timestamp, broadcast, group, track}; +use moq_net::{Timescale, Timestamp, broadcast, track}; use object_store::ObjectStore; use tokio::sync::watch; @@ -98,12 +98,6 @@ struct TimelineRequests { dynamic: track::Dynamic, } -struct TimelineRequest { - name: String, - timescale: Option, - request: group::Request, -} - struct Shared { store: Store, index: Mutex, @@ -358,83 +352,102 @@ async fn serve(shared: Arc>, mut dynamic: broadcast::D /// Serve a timeline's evicted groups from their original stored objects. async fn serve_timeline(shared: Arc>, requests: TimelineRequests) { - let mut groups = kio::Tasks::new(); + let mut fills = kio::Tasks::new(); kio::wait(|waiter| { loop { - match requests.dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(request)) => { - groups.push(task(Box::pin(serve_timeline_group( - shared.clone(), - TimelineRequest { - name: requests.name.clone(), - timescale: requests.timescale, - request, - }, - )))); + match requests.dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { + let (shared, name, timescale) = (shared.clone(), requests.name.clone(), requests.timescale); + fills.push(task(Box::pin(async move { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + serve_timeline_group(&shared, &name, timescale, &fill, sequence).await; + } + }))); } Poll::Ready(Err(_)) => return Poll::Ready(()), Poll::Pending => break, } } - let _ = groups.poll(waiter); + let _ = fills.poll(waiter); Poll::Pending }) .await; } -async fn serve_timeline_group(shared: Arc>, request: TimelineRequest) { - let TimelineRequest { - name, - timescale, - request, - } = request; - let sequence = request.sequence(); +/// Answer group `sequence`, which the archive failed to load. A store failure may pass, so +/// only the readers waiting now are refused; anything else means the archive can't serve it. +fn unavailable(fill: &track::Fill, sequence: u64, err: &Error) { + let groups = sequence..sequence + 1; + match err { + Error::Store(_) => fill.refuse_groups(groups, moq_net::Error::Stream(moq_net::StreamError::Internal)), + _ => fill.drop_groups(groups), + } +} + +async fn serve_timeline_group( + shared: &Shared, + name: &str, + timescale: Option, + fill: &track::Fill, + sequence: u64, +) { + let reject = || fill.drop_groups(sequence..sequence + 1); let segment = shared .timelines .lock() .unwrap() - .get(&name) + .get(name) .and_then(|groups| groups.get(&sequence)) .copied(); let Some(segment) = segment else { - request.reject(moq_net::Error::NotFound); + reject(); return; }; - let key = match Key::segments(&name, segment) { + let key = match Key::segments(name, segment) { Ok(key) => key, Err(_) => { - request.reject(moq_net::Error::NotFound); + reject(); return; } }; - let object = shared - .cache - .get_or_insert_async(&key, async { + let object = tokio::select! { + biased; + // Every reader narrowed past it: the fill moves on to the groups it still owes. + _ = fill.unwanted(sequence) => return, + object = shared.cache.get_or_insert_async(&key, async { shared .store - .get_segments(&name, segment, timescale.is_some()) + .get_segments(name, segment, timescale.is_some()) .await .map(Arc::new) - }) - .await; + }) => object, + }; let object = match object { Ok(object) => object, Err(err) => { tracing::warn!(track = name, sequence, %err, "archived timeline group is unavailable"); - request.reject(moq_net::Error::NotFound); + unavailable(fill, sequence, &err); return; } }; - let start = request.frame_start(); + let start = fill.frame_start(sequence); let Some(stored) = object.groups.iter().find(|group| group.sequence == sequence) else { - request.reject(moq_net::Error::NotFound); + reject(); return; }; if object.frame_start != 0 { - request.reject(moq_net::Error::NotFound); + reject(); + return; + } + if !fill.is_wanted(sequence) { return; } - let Ok(mut producer) = request.accept(None) else { + let Ok(mut producer) = fill.accept(sequence, None) else { return; }; if producer.start_at(start).is_err() { @@ -452,7 +465,7 @@ async fn serve_timeline_group(shared: Arc>, request: T let _ = producer.finish(); } -/// Accept a track a timeline indexes, then serve its group requests until nobody uses it. +/// Accept a track a timeline indexes, then serve its fills until nobody uses it. async fn serve_track(shared: Arc>, request: track::Request, known: bool) { let name = request.name().to_string(); if !known { @@ -480,20 +493,30 @@ async fn serve_track(shared: Arc>, request: track::Req let dynamic = request.dynamic(); let mut demand = dynamic.demand(); let _producer = request.accept(info); - let mut groups = kio::Tasks::new(); + let mut fills = kio::Tasks::new(); kio::wait(|waiter| { loop { - match dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(request)) => { - let serve = serve_group(shared.clone(), name.clone(), timescale, request); - groups.push(task(Box::pin(serve))); + match dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { + let (shared, name) = (shared.clone(), name.clone()); + fills.push(task(Box::pin(async move { + // One group at a time: a fill's groups precede its readers' live edge. + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + serve_group(&shared, &name, timescale, &fill, sequence).await; + } + }))); } Poll::Ready(Err(_)) => return Poll::Ready(()), Poll::Pending => break, } } - if groups.poll(waiter).is_pending() { + if fills.poll(waiter).is_pending() { return Poll::Pending; } // Drain the changes so the waiter stays registered, then read where demand settled. @@ -506,41 +529,55 @@ async fn serve_track(shared: Arc>, request: track::Req .await } -/// Answer one group request from the records holding it, or reject it as never delivered. +/// Answer one group of a fill from the records holding it, or drop it as never delivered. /// /// A group whose records stop short of its end keeps its producer open and grows as later records /// commit, until one reaches the group's end or the caller finishes the reader. async fn serve_group( - shared: Arc>, - track: String, + shared: &Shared, + track: &str, timescale: Option, - request: group::Request, + fill: &track::Fill, + sequence: u64, ) { - let sequence = request.sequence(); - let start = request.frame_start(); + let start = fill.frame_start(sequence); let mut changed = shared.changed.subscribe(); - let mut spans = shared.index.lock().unwrap().group(&track, sequence); + let mut spans = shared.index.lock().unwrap().group(track, sequence); - // The earliest retained record must hold the requested frame; an expired head is gone. - if spans - .first() - .is_none_or(|span| span.start > Position::new(sequence, start)) - { - request.reject(moq_net::Error::NotFound); - return; + // The earliest retained record must hold the requested frame. Nothing retained is gone; + // an expired head is refused for this request alone, since a later one may ask the tail. + match spans.first() { + None => { + fill.drop_groups(sequence..sequence + 1); + return; + } + Some(span) if span.start > Position::new(sequence, start) => { + fill.refuse_groups(sequence..sequence + 1, moq_net::Error::NotFound); + return; + } + Some(_) => {} } // Validate the first object before accepting, so an unreadable group is a plain miss. Keep it, // so a cache too small to hold it costs no second GET. let timed = timescale.is_some(); - let mut first = match object(&shared, &track, timed, &spans[0]).await { + let loaded = tokio::select! { + biased; + // Every reader narrowed past it: the fill moves on to the groups it still owes. + _ = fill.unwanted(sequence) => return, + loaded = object(shared, track, timed, &spans[0]) => loaded, + }; + let mut first = match loaded { Ok(object) => Some((spans[0].sequence, object)), Err(err) => { tracing::warn!(track, sequence, %err, "archived group is unavailable"); - request.reject(moq_net::Error::NotFound); + unavailable(fill, sequence, &err); return; } }; - let Ok(mut producer) = request.accept(None) else { + if !fill.is_wanted(sequence) { + return; + } + let Ok(mut producer) = fill.accept(sequence, None) else { return; }; if producer.start_at(start).is_err() { @@ -555,7 +592,7 @@ async fn serve_group( } let loaded = match first.take_if(|(loaded, _)| *loaded == span.sequence) { Some((_, object)) => Ok(object), - None => object(&shared, &track, timed, span).await, + None => object(shared, track, timed, span).await, }; let object = match loaded { Ok(object) if span.start <= Position::new(sequence, next) => object, @@ -612,7 +649,7 @@ async fn serve_group( } } changed.mark_unchanged(); - spans = shared.index.lock().unwrap().group(&track, sequence); + spans = shared.index.lock().unwrap().group(track, sequence); } } diff --git a/rs/moq-archive/src/reader/tests.rs b/rs/moq-archive/src/reader/tests.rs index 6f4e7c4613c..47d73a51092 100644 --- a/rs/moq-archive/src/reader/tests.rs +++ b/rs/moq-archive/src/reader/tests.rs @@ -763,6 +763,75 @@ async fn evicted_timeline_groups_remain_fetchable() { let _ = serve.await; } +/// A timeline group every reader narrowed past stops its GET, so the groups the fill still +/// owes are read rather than waiting behind it. +#[tokio::test(start_paused = true)] +async fn a_narrowed_timeline_read_skips_the_group_it_left() { + let mut archive = Archive::new().await; + for group in 0..3 { + archive.add("video", group..group + 1, 1).await; + } + let mut info = broadcast::Info::new(); + info.pool = moq_net::cache::Pool::new(moq_net::cache::Config::default().with_expiry(Duration::from_millis(100))); + let pool = info.pool.clone(); + let broadcast = info.produce(); + let reader = Reader::open(archive.store.clone(), &broadcast, archive.config()) + .await + .unwrap(); + let serve = tokio::spawn(reader.serve()); + let now = moq_net::time::Instant::now(); + pool.poll(now, &kio::Waiter::noop()); + pool.poll(now + Duration::from_secs(1), &kio::Waiter::noop()); + + let mock = archive.store.inner().clone(); + mock.take(); + let gate = mock.stall_gets("video%2Etimeline%2Ez/segments/"); + let ascending = |groups| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_range(groups) + .with_order(moq_net::track::Order::Ascending) + }; + let mut subscriber = broadcast + .consume() + .track(&timeline("video")) + .unwrap() + .subscribe(ascending(0..)) + .await + .unwrap(); + let waiter = kio::Waiter::noop(); + let mut gets = Vec::new(); + let _ = subscriber.poll_recv_group(&waiter); + assert!( + segment_read(&mock, &mut gets, 0).await, + "group 0 is read from its segment" + ); + + subscriber.update(ascending(1..)).unwrap(); + let _ = subscriber.poll_recv_group(&waiter); + assert!( + segment_read(&mock, &mut gets, 1).await, + "group 1 waited behind group 0, which nobody reads" + ); + gate.add_permits(16); + serve.abort(); + let _ = serve.await; +} + +/// Whether the video timeline's `segment` is requested once the runtime settles, collecting +/// GETs. Bounded by yields rather than time, so the paused clock stays put. +async fn segment_read(mock: &Mock, gets: &mut Vec, segment: u64) -> bool { + let path = format!("rec/video%2Etimeline%2Ez/segments/{segment:019}"); + for _ in 0..1000 { + gets.extend(mock.gets()); + if gets.contains(&path) { + return true; + } + tokio::task::yield_now().await; + } + false +} + #[tokio::test(start_paused = true)] async fn enrolled_timeline_fetch_survives_finish() { let mut archive = Archive::new().await; diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index cf157ebef02..f1b4d2c9f39 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -2124,7 +2124,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(observed.subscription().unwrap().max_delay, max_delay); + assert_eq!(observed.subscription().unwrap().max_delay, Some(max_delay)); let samples = [0.25f32, -0.5, 0.75, -1.0]; let payload: Vec = samples.iter().flat_map(|sample| sample.to_le_bytes()).collect(); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 5aaf0f8fe79..71ab0df5f85 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -2961,6 +2961,8 @@ pub unsafe extern "C" fn moq_publish_dynamic( /// [moq_publish_dynamic_cancel] was called) or a negative error. After the terminal /// (`<= 0`) callback, `user_data` is never touched again. Cached groups never reach the /// handler. Answer each request with [moq_group_request_accept] or [moq_group_request_abort]. +/// A request whose group nobody reads any more is withdrawn: answering it then fails as not +/// found, so move on to the next request. /// /// Returns a non-zero handle on success, or a negative code on failure. /// @@ -3174,9 +3176,10 @@ pub extern "C" fn moq_group_request_accept(request: u32) -> i32 { }) } -/// Reject a group request with an application error code, failing the waiting fetches. +/// Reject a group request, failing the waiting fetches as not found. /// -/// Consumes the request handle. Returns a zero on success, or a negative code on failure. +/// The error code is not delivered. Consumes the request handle. Returns a zero on success, +/// or a negative code on failure. #[unsafe(no_mangle)] pub extern "C" fn moq_group_request_abort(request: u32, error_code: u16) -> i32 { ffi::enter(move || { diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 2df5ad18df0..d14290c2caf 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::sync::Arc; use moq_mux::catalog::hang::Extra; use moq_mux::import; @@ -24,10 +25,19 @@ struct TrackRequest { } /// A request a handler pulled, before it has a handle. A track request is boxed: it is -/// several times the size of a group request. +/// several times the size of a fill. enum Request { Track(Box), - Group(moq_net::group::Request), + Fill(moq_net::track::Fill), +} + +/// One group of a fill, handed to C as a group request: the frozen ABI answers fills a +/// group at a time. +struct GroupRequest { + fill: Arc, + sequence: u64, + /// Dropped once the request is answered or freed, so its fill's next group follows. + _pending: oneshot::Sender<()>, } /// What a request handler serves. @@ -194,7 +204,7 @@ pub struct Publish { track_request: NonZeroSlab, /// Group requests delivered to a handler, freed on accept, abort, or free. - group_request: NonZeroSlab, + group_request: NonZeroSlab, /// Finished broadcasts, serving their finished tracks until closed. finishes: NonZeroSlab, @@ -709,23 +719,28 @@ impl Publish { mut dynamic: Dynamic, mut close: oneshot::Receiver<()>, ) -> Result<(), Error> { + // Each fill hands out its groups one at a time, alongside the other fills. + let mut fills = kio::Tasks::new(); loop { // The handler is owned here, so returning drops it and rejects whatever is pending. // `biased` so a pending close always wins over a ready request. let res = tokio::select! { biased; _ = &mut close => return Ok(()), - res = kio::wait(|waiter| match &mut dynamic { - Dynamic::Broadcast(dynamic, broadcast, finished) => { - dynamic.poll_requested_track(waiter).map_ok(|request| { - Request::Track(Box::new(TrackRequest { - broadcast: *broadcast, - finished: finished.clone(), - request, - })) - }) + res = kio::wait(|waiter| { + let _ = fills.poll(waiter); + match &mut dynamic { + Dynamic::Broadcast(dynamic, broadcast, finished) => { + dynamic.poll_requested_track(waiter).map_ok(|request| { + Request::Track(Box::new(TrackRequest { + broadcast: *broadcast, + finished: finished.clone(), + request, + })) + }) + } + Dynamic::Track(dynamic) => dynamic.poll_requested_fill(waiter).map_ok(Request::Fill), } - Dynamic::Track(dynamic) => dynamic.poll_requested_group(waiter).map_ok(Request::Group), }) => res, }; @@ -737,13 +752,45 @@ impl Publish { }; // Hold the lock only to buffer the request; release it before the callback. - let mut state = State::lock(); let id = match request { - Request::Track(request) => state.publish.track_request.insert(*request)?, - Request::Group(request) => state.publish.group_request.insert(request)?, + Request::Track(request) => State::lock().publish.track_request.insert(*request)?, + Request::Fill(fill) => { + let mut serve = Box::pin(Self::serve_fill(callback, Arc::new(fill))); + fills.push(move |waiter: &kio::Waiter| waiter.poll_future(serve.as_mut())); + continue; + } + }; + callback.call(id); + } + } + + /// Hand `fill` to C a group at a time, each once the one before it is answered or nobody + /// reads it any more, until every group is answered or nobody waits on the fill. + async fn serve_fill(callback: OnStatus, fill: Arc) { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + let (pending, answered) = oneshot::channel(); + let request = GroupRequest { + fill: fill.clone(), + sequence, + _pending: pending, + }; + let Ok(id) = State::lock().publish.group_request.insert(request) else { + return; }; - drop(state); callback.call(id); + let unwanted = kio::wait(|waiter| fill.poll_unwanted(sequence, waiter)); + tokio::select! { + _ = answered => {} + // Withdrawn, so answering it fails as not found, and the fill moves on. + _ = unwanted => { + State::lock().publish.group_request.remove(id); + } + } } } @@ -818,7 +865,11 @@ impl Publish { /// The sequence, priority, and first frame of a requested group. pub fn group_request_info(&self, request: Id) -> Result<(u64, i8, u64), Error> { let request = self.group_request.get(request).ok_or(Error::NotFound)?; - Ok((request.sequence(), request.priority(), request.frame_start())) + Ok(( + request.sequence, + request.fill.priority(), + request.fill.frame_start(request.sequence), + )) } /// Accept a group request, returning a group handle like [`Self::track_group`]. @@ -827,25 +878,20 @@ impl Publish { /// indices they have in the group rather than restarting at 0. pub fn group_request_accept(&mut self, request: Id) -> Result { let request = self.group_request.remove(request).ok_or(Error::NotFound)?; - let frame_start = request.frame_start(); - let mut group = request.accept(None)?; - if let Err(err) = group.start_at(frame_start) { - let _ = group.abort(err.clone()); - return Err(err.into()); - } + let group = request.fill.accept(request.sequence, None)?; self.groups.insert(group) } - /// Reject a group request, failing every fetch waiting on it with `error_code`. - pub fn group_request_abort(&mut self, request: Id, error_code: u16) -> Result<(), Error> { - let request = self.group_request.remove(request).ok_or(Error::NotFound)?; - request.reject(moq_net::Error::App(error_code)); - Ok(()) + /// Reject a group request, failing every fetch waiting on it. A fill has no error code + /// to carry, so `error_code` is not delivered: the fetches fail as not found. + pub fn group_request_abort(&mut self, request: Id, _error_code: u16) -> Result<(), Error> { + self.group_request_free(request) } /// Drop a group request, which rejects it. pub fn group_request_free(&mut self, request: Id) -> Result<(), Error> { - self.group_request.remove(request).ok_or(Error::NotFound)?; + let request = self.group_request.remove(request).ok_or(Error::NotFound)?; + request.fill.drop_groups(request.sequence..request.sequence + 1); Ok(()) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index bd55cdacc5b..7d9147131a7 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3328,7 +3328,7 @@ fn track_dynamic_serves_a_fetch_miss() { assert_eq!(moq_publish_group_finish(group), 0); assert_eq!(fetch.recv_timeout(TIMEOUT).unwrap().unwrap(), (0, payload.to_vec())); - // A rejected fetch fails with the application code. + // A rejected fetch fails as not found. let fetch = fetch_group(broadcast, "data", 6, 0, 0); let request = id(group_cb.recv()); assert_eq!(moq_group_request_abort(request, 9), 0); @@ -3336,7 +3336,7 @@ fn track_dynamic_serves_a_fetch_miss() { .recv_timeout(TIMEOUT) .unwrap() .expect_err("a rejected fetch fails"); - assert!(matches!(err, moq_net::Error::App(9)), "got {err:?}"); + assert!(matches!(err, moq_net::Error::NotFound), "got {err:?}"); assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); @@ -3345,6 +3345,60 @@ fn track_dynamic_serves_a_fetch_miss() { assert_eq!(moq_origin_close(origin), 0); } +/// A group request every reader narrowed past is withdrawn, and the next group its fill +/// still owes is requested without waiting for an answer. +#[test] +fn a_group_request_nobody_reads_is_withdrawn() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"track-dynamic-narrowed"); + let name = b"data"; + let track = + id(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), std::ptr::null()) }); + let group_cb = Callback::new(); + let dynamic = id(unsafe { moq_publish_track_dynamic(track, Some(channel_callback), group_cb.ptr) }); + + let ascending = |start, end| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_range(start..end) + .with_order(moq_net::track::Order::Ascending) + }; + let mut reader = { + let mut state = State::lock(); + let producer = state.publish.track_producer(Id::try_from(track).unwrap()).unwrap(); + let reader = producer.subscribe(ascending(7, u64::MAX)); + producer.start_at(10).unwrap(); + let mut group = producer.create_group(moq_net::group::Info { sequence: 10 }).unwrap(); + group + .write_frame(moq_net::Timestamp::from_millis(10).unwrap(), &b"live"[..]) + .unwrap(); + group.finish().unwrap(); + reader + }; + let waiter = kio::Waiter::noop(); + while let std::task::Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + let first = id(group_cb.recv()); + let mut sequence = 0u64; + assert_eq!(unsafe { moq_group_request_sequence(first, &mut sequence) }, 0); + assert_eq!(sequence, 7); + + // Reading only 8, so the fill owes nothing once 8 is let go. + reader.update(ascending(8, 9)).unwrap(); + assert!(reader.poll_recv_group(&waiter).is_pending()); + let next = id(group_cb.recv()); + assert_eq!(unsafe { moq_group_request_sequence(next, &mut sequence) }, 0); + assert_eq!(sequence, 8, "group 7, which nobody reads, held the fill back"); + assert!(moq_group_request_accept(first) < 0, "the withdrawn request"); + + assert_eq!(moq_group_request_free(next), 0); + drop(reader); + assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); + assert_eq!(group_cb.recv_terminal(), 0); + assert_eq!(moq_publish_track_finish(track), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn track_dynamic_serves_a_fetch_from_frame_start() { let origin = id(moq_origin_create()); diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 1c48101c65f..660d6389b36 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -31,13 +31,13 @@ pub struct MoqSubscription { /// The default 0 is the midpoint. #[uniffi(default = 0)] pub priority: i8, - /// Maximum age of a non-latest group before it is skipped, in microseconds, or null - /// for moq-net's default: `0`, which skips immediately. A larger value tolerates that - /// much reordering. + /// Maximum age of a non-latest group before it is skipped, in microseconds. + /// `0` skips immediately; a larger value tolerates that much reordering; null leaves + /// the publisher's max age alone to bound it. /// /// Enforced both by the publisher's cache (sent on the wire) and by any local /// buffering, such as [`MoqMediaTrackConsumer`]'s jitter buffer. It caps every range. - #[uniffi(default = None)] + #[uniffi(default = Some(0))] pub max_delay_us: Option, /// The spans of the track to deliver, or null for everything `max_delay_us` still /// considers fresh. An empty list asks for nothing. @@ -130,6 +130,15 @@ impl From for moq_net::track::Order { } } +impl From for MoqOrder { + fn from(order: moq_net::track::Order) -> Self { + match order { + moq_net::track::Order::Ascending => Self::Ascending, + moq_net::track::Order::Descending => Self::Descending, + } + } +} + /// Options for fetching one past group by sequence. #[derive(Clone, uniffi::Record)] pub struct MoqFetchGroupOptions { @@ -156,10 +165,9 @@ impl TryFrom for moq_net::track::Subscription { type Error = MoqError; fn try_from(s: MoqSubscription) -> Result { - let mut subscription = moq_net::track::Subscription::default().with_priority(s.priority); - if let Some(max_delay) = s.max_delay_us { - subscription = subscription.with_max_delay(std::time::Duration::from_micros(max_delay)); - } + let mut subscription = moq_net::track::Subscription::default() + .with_priority(s.priority) + .with_max_delay(s.max_delay_us.map(std::time::Duration::from_micros)); if let Some(ranges) = s.ranges { subscription = subscription.with_ranges( ranges diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 646bae7fc5b..bb92c712442 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use moq_mux::catalog::hang::Extra; -use crate::consumer::{MoqBroadcastConsumer, MoqGroupConsumer, MoqSubscription, MoqTrackConsumer}; +use crate::consumer::{MoqBroadcastConsumer, MoqGroupConsumer, MoqOrder, MoqSubscription, MoqTrackConsumer}; use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::{Background, Task}; @@ -185,9 +185,9 @@ impl DynamicProducer { } impl TrackDynamicProducer { - async fn requested_group(&mut self) -> Result, MoqError> { - let request = self.inner.requested_group().await?; - Ok(Arc::new(MoqGroupRequest::new(request))) + async fn requested_fill(&mut self) -> Result, MoqError> { + let fill = self.inner.requested_fill().await?; + Ok(Arc::new(MoqFill { inner: Arc::new(fill) })) } } @@ -464,17 +464,17 @@ impl MoqBroadcastDynamic { #[uniffi::export] impl MoqTrackDynamic { - /// Wait for the next fetch of an uncached group. + /// Wait for the next run of groups readers miss: a fetch of an uncached group, or + /// history a subscription reads that the cache no longer holds. /// - /// Accept the returned request to produce the group, or abort it with an - /// application error. Cached groups are served without reaching this method. - pub async fn requested_group(&self) -> Result, MoqError> { + /// Cached groups are served without reaching this method. + pub async fn requested_fill(&self) -> Result, MoqError> { self.task - .run(|mut state| async move { state.requested_group().await }) + .run(|mut state| async move { state.requested_fill().await }) .await } - /// Cancel all current and future `requested_group()` calls. + /// Cancel all current and future `requested_fill()` calls. /// /// Terminal: the dynamic track is released here, not when the handle is, so any pending /// fetch is rejected. @@ -483,55 +483,104 @@ impl MoqTrackDynamic { } } -/// An uncached group requested by a fetch consumer. +/// A run of groups readers miss, mirroring [`moq_net::track::Fill`]. +/// +/// Call [`accept`](Self::accept) for each group you have and [`drop_groups`](Self::drop_groups) +/// for the rest; [`next`](Self::next) names what is still owed, in the fill's order. Releasing +/// it names whatever is left as dropped while a reader still wants it; once nobody does, it is +/// withdrawn, so stop serving it when [`unused`](Self::unused) resolves. Stop serving a single +/// group when [`unwanted`](Self::unwanted) resolves for it, and move on to the next. #[derive(uniffi::Object)] -pub struct MoqGroupRequest { - sequence: u64, - priority: i8, - inner: std::sync::Mutex>, +pub struct MoqFill { + inner: Arc, } -impl MoqGroupRequest { - fn new(request: moq_net::group::Request) -> Self { - Self { - sequence: request.sequence(), - priority: request.priority(), - inner: std::sync::Mutex::new(Some(request)), - } +#[uniffi::export] +impl MoqFill { + /// The first group to fill. + pub fn start_group(&self) -> u64 { + self.inner.start().group } - fn take(&self) -> Result { - self.inner.lock().unwrap().take().ok_or(MoqError::Closed) + /// The frame within the first group to start from. + pub fn start_frame(&self) -> u64 { + self.inner.start().frame } -} -#[uniffi::export] -impl MoqGroupRequest { - /// The requested group sequence within the track. - pub fn sequence(&self) -> u64 { - self.sequence + /// The exclusive end group. + pub fn end_group(&self) -> u64 { + self.inner.end() } - /// The consumer's delivery priority for this fetch. + /// The most urgent delivery priority among the readers waiting on it. pub fn priority(&self) -> i8 { - self.priority + self.inner.priority() } - /// Accept the request and return a producer for filling the fetched group. - pub fn accept(&self) -> Result, MoqError> { + /// The most tolerant age budget among the readers waiting on it, in microseconds, or null + /// for the publisher's max age alone. + pub fn max_delay_us(&self) -> Option { + self.inner + .max_delay() + .map(|max_delay| u64::try_from(max_delay.as_micros()).unwrap_or(u64::MAX)) + } + + /// Which end to fill first. + pub fn order(&self) -> MoqOrder { + self.inner.order().into() + } + + /// The next group still owed in the fill's order, or null once every group is answered. + pub fn next(&self) -> Option { + self.inner.next() + } + + /// Insert group `sequence` and return a producer for filling it. The first group starts + /// at [`start_frame`](Self::start_frame), so its frames keep their indices. + pub fn accept(&self, sequence: u64) -> Result, MoqError> { let _guard = crate::ffi::enter(); - let group = self.take()?.accept(None)?; + let group = self.inner.accept(sequence, None)?; Ok(Arc::new(MoqGroupProducer { sequence: group.sequence, inner: std::sync::Mutex::new(Some(group)), })) } - /// Reject the fetch with an application error code. - pub fn abort(&self, error_code: u16) -> Result<(), MoqError> { + /// Name groups `start` up to the exclusive `end` as never to be delivered. + pub fn drop_groups(&self, start: u64, end: u64) { let _guard = crate::ffi::enter(); - self.take()?.reject(moq_net::Error::App(error_code)); - Ok(()) + self.inner.drop_groups(start..end); + } + + /// Whether a reader still waits on this fill. + pub fn is_used(&self) -> bool { + self.inner.is_used() + } + + /// Wait until no reader waits on this fill. + pub async fn unused(&self) -> Result<(), MoqError> { + let fill = self.inner.clone(); + crate::ffi::detached(async move { + fill.unused().await; + Ok::<_, MoqError>(()) + }) + .await + } + + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. + pub fn is_wanted(&self, sequence: u64) -> bool { + self.inner.is_wanted(sequence) + } + + /// Wait until no reader waits for group `sequence` from this fill, so stop serving it. + pub async fn unwanted(&self, sequence: u64) -> Result<(), MoqError> { + let fill = self.inner.clone(); + crate::ffi::detached(async move { + fill.unwanted(sequence).await; + Ok::<_, MoqError>(()) + }) + .await } } diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 0c3b41e0e7f..1377af14ec3 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -2460,14 +2460,18 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { .await }); - let request = tokio::time::timeout(TIMEOUT, dynamic.requested_group()) + let request = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - assert_eq!(request.sequence(), 5); + assert_eq!( + (request.start_group(), request.start_frame(), request.end_group()), + (5, 0, 6) + ); assert_eq!(request.priority(), 11); + assert_eq!(request.next(), Some(5)); - let group = request.accept().unwrap(); + let group = request.accept(5).unwrap(); group .write_frame(MoqFrame { payload: b"fetched".to_vec(), @@ -2487,6 +2491,35 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { assert_eq!(frame.timestamp_us, Some(100_000)); } +#[tokio::test] +async fn fill_goes_unused_when_the_fetcher_leaves() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let track = broadcast.publish_track("events".into(), None).unwrap(); + let dynamic = track.dynamic().unwrap(); + let consumer = broadcast.consume().unwrap(); + + let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); + let fill = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) + .await + .expect("timed out waiting for fill") + .unwrap(); + assert!(fill.is_used()); + assert!(fill.is_wanted(5)); + assert!(!fill.is_wanted(6), "outside the fill"); + + fetch.abort(); + tokio::time::timeout(TIMEOUT, fill.unused()) + .await + .expect("timed out waiting for the abandoned fetch to become unused") + .unwrap(); + assert!(!fill.is_used()); + tokio::time::timeout(TIMEOUT, fill.unwanted(5)) + .await + .expect("an unused fill wants none of its groups") + .unwrap(); + assert!(!fill.is_wanted(5)); +} + #[tokio::test] async fn dynamic_track_rejects_fetch_miss() { let broadcast = MoqBroadcastProducer::new().unwrap(); @@ -2495,26 +2528,19 @@ async fn dynamic_track_rejects_fetch_miss() { let consumer = broadcast.consume().unwrap(); let fetch = tokio::spawn(async move { consumer.fetch_group("events".into(), 5, None).await }); - let request = tokio::time::timeout(TIMEOUT, dynamic.requested_group()) + let fill = tokio::time::timeout(TIMEOUT, dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - request.abort(404).unwrap(); + fill.drop_groups(5, 6); + assert_eq!(fill.next(), None, "the dropped group is answered"); let result = tokio::time::timeout(TIMEOUT, fetch) .await .expect("timed out waiting for rejected fetch") .expect("fetch task panicked"); - match result { - Err(MoqError::Protocol { details: protocol }) => { - assert_eq!(protocol.scope, crate::error::MoqErrorScope::Stream); - assert_eq!(protocol.code, 64 + 404); - assert_eq!(protocol.kind, crate::error::MoqProtocolKind::App); - } - Err(other) => panic!("expected Protocol App(404), got {other:?}"), - Ok(_) => panic!("expected Protocol App(404), got a group"), - } - assert!(matches!(request.accept(), Err(MoqError::Closed))); + assert!(matches!(result, Err(MoqError::NotFound)), "got {:?}", result.err()); + assert!(fill.accept(5).is_err(), "a dropped group is answered already"); } #[tokio::test] @@ -2550,12 +2576,12 @@ async fn requested_track_dynamic_survives_accept() { let track_dynamic = request.dynamic().unwrap(); let _track = request.accept(Some(timed_info())).unwrap(); - let group_request = tokio::time::timeout(TIMEOUT, track_dynamic.requested_group()) + let fill = tokio::time::timeout(TIMEOUT, track_dynamic.requested_fill()) .await - .expect("timed out waiting for group request") + .expect("timed out waiting for fill") .unwrap(); - assert_eq!(group_request.sequence(), 9); - let group = group_request.accept().unwrap(); + assert_eq!(fill.start_group(), 9); + let group = fill.accept(9).unwrap(); group .write_frame(MoqFrame { payload: b"archive".to_vec(), diff --git a/rs/moq-flate/src/snapshot/consumer.rs b/rs/moq-flate/src/snapshot/consumer.rs index a815fce562e..ede337e365b 100644 --- a/rs/moq-flate/src/snapshot/consumer.rs +++ b/rs/moq-flate/src/snapshot/consumer.rs @@ -144,7 +144,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::snapshot::Config { compression }; let mut producer = crate::snapshot::Producer::new(track, config.clone()); diff --git a/rs/moq-flate/src/snapshot/mod.rs b/rs/moq-flate/src/snapshot/mod.rs index 39ed7583a47..c4ade801e34 100644 --- a/rs/moq-flate/src/snapshot/mod.rs +++ b/rs/moq-flate/src/snapshot/mod.rs @@ -124,7 +124,7 @@ mod test { fn next_preserves_intermediate_states() { let (mut producer, mut track) = producer(true); let mut subscription = track.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; track.update(subscription).unwrap(); for value in [b"one".as_slice(), b"two", b"three"] { producer.update(value).unwrap(); diff --git a/rs/moq-flate/src/stream/consumer.rs b/rs/moq-flate/src/stream/consumer.rs index e52d48e678e..a555b242e54 100644 --- a/rs/moq-flate/src/stream/consumer.rs +++ b/rs/moq-flate/src/stream/consumer.rs @@ -125,7 +125,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::stream::Config { compression }; let mut producer = crate::stream::Producer::new(track, config.clone()); diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index 7643e72c4b0..112a71a3255 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -825,7 +825,12 @@ impl Expired { .await .unwrap() .ordered(); - let retained = retained.next_group().await.unwrap(); + // Polled once, before the reader serves anything: a fill would answer group 0 from the + // archive, so only what the cache still holds is ready now. + let retained = match retained.poll_next_group(&kio::Waiter::noop()) { + std::task::Poll::Ready(group) => group.unwrap(), + std::task::Poll::Pending => None, + }; assert!( retained.is_none_or(|group| group.sequence > 0), "the initial timeline groups must really be evicted" diff --git a/rs/moq-json/src/snapshot/consumer.rs b/rs/moq-json/src/snapshot/consumer.rs index 22bb2cbca8c..99de8b14ac9 100644 --- a/rs/moq-json/src/snapshot/consumer.rs +++ b/rs/moq-json/src/snapshot/consumer.rs @@ -182,7 +182,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::snapshot::Config { compression, diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index 25c1b16cf25..d7248556ff0 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -222,7 +222,7 @@ mod test { fn next_preserves_intermediate_states() { let (mut producer, mut track) = producer(cfg(true)); let mut subscription = track.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; track.update(subscription).unwrap(); for n in [1, 2, 3] { producer diff --git a/rs/moq-json/src/stream/consumer.rs b/rs/moq-json/src/stream/consumer.rs index 12c0a548924..be0adcf89b5 100644 --- a/rs/moq-json/src/stream/consumer.rs +++ b/rs/moq-json/src/stream/consumer.rs @@ -120,7 +120,7 @@ mod timestamps { .unwrap(); let mut subscriber = track.subscribe(None); let mut subscription = subscriber.subscription(); - subscription.max_delay = std::time::Duration::MAX; + subscription.max_delay = None; subscriber.update(subscription).unwrap(); let config = crate::stream::Config { compression }; let mut producer = crate::stream::Producer::new(track, config.clone()); diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 8ebd1a83fd4..5b2827f27a4 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -198,6 +198,7 @@ impl Consumer { let start = track.floor(); let max_delay = subscription .max_delay + .unwrap_or(std::time::Duration::MAX) .min(track.info().max_age.unwrap_or(std::time::Duration::MAX)); Self { name: track.name().into(), diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index a64ca308277..2fb0653bad3 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -1027,7 +1027,7 @@ mod tests { }) .await; - assert_eq!(observed, max_delay, "live start: {live_start}"); + assert_eq!(observed, Some(max_delay), "live start: {live_start}"); } } diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index 93d9c01f2d7..a8cffc80d8f 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -85,6 +85,11 @@ //! the readers: an unsettled time end walks on from where it stopped, so a rescan of the //! groups behind the gap on every read shows up as a slope over them. //! +//! `track_fill` has readers miss the same runs below where the feed starts, each asked of the +//! track's handler as a fill, swept over the readers and the runs: readers share each +//! fill, so asking, dropping, and settling must cost each reader the same per run, and a +//! cost that grows with the audience or the runs beyond that shows up as a slope. +//! //! Run with `cargo bench -p moq-net --bench track`. #![expect( @@ -1640,6 +1645,84 @@ fn bench_time_end(c: &mut Criterion) { group.finish(); } +/// Readers missing the same runs of one track below where its feed starts, and the handler +/// they ask; see [`bench_fill`]. +struct Fills { + _broadcast: broadcast::Producer, + _track: track::Producer, + dynamic: track::Dynamic, + readers: Vec, + runs: u64, +} + +impl Fills { + fn new(readers: usize, runs: u64) -> Self { + let broadcast = broadcast::Info::default().produce(); + let mut track = broadcast.create_track("bench", timed()).unwrap(); + let dynamic = track.dynamic(); + // The feed starts past every run, so each is a miss asked at once. + let start = runs * 2; + track.start_at(start).unwrap(); + let mut group = track.create_group(group::Info { sequence: start }).unwrap(); + group + .write_frame(Timestamp::from_millis(start).unwrap(), Bytes::from_static(b"x")) + .unwrap(); + group.finish().unwrap(); + // One range per run, with a sequence between each that no reader asks for. + let ranges = (0..runs) + .map(|run| track::Range::groups(run * 2..run * 2 + 1)) + .chain([track::Range::groups(start..)]); + let subscription = track::Subscription::default() + .with_ranges(ranges) + .with_max_delay(AGED_BUDGET); + let readers = (0..readers).map(|_| track.subscribe(subscription.clone())).collect(); + Self { + _broadcast: broadcast, + _track: track, + dynamic, + readers, + runs, + } + } + + /// Every reader reads what is cached and asks for its misses, the handler drops each + /// fill they share, and every reader settles the drops. + fn run(&mut self) { + let waiter = kio::Waiter::noop(); + for reader in &mut self.readers { + while let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) { + black_box(group); + } + } + let mut fills = 0; + while let Poll::Ready(Ok(fill)) = self.dynamic.poll_requested_fill(&waiter) { + fill.drop_groups(fill.start().group..fill.end()); + fills += 1; + } + assert_eq!(fills, self.runs, "readers share each run's fill"); + for reader in &mut self.readers { + assert!(reader.poll_recv_group(&waiter).is_pending()); + } + } +} + +/// Readers missing the same runs below the feed's start, each asked as one fill they share, +/// swept over the readers and the runs. Each reader must pay the same per run, so a cost +/// that grows with the audience or the runs shows up as a slope. +fn bench_fill(c: &mut Criterion) { + let mut group = c.benchmark_group("track_fill"); + group.sample_size(10); + for readers in [1usize, 8, 64] { + for runs in [1u64, 64, 1024] { + group.throughput(Throughput::Elements(runs * readers as u64)); + group.bench_function(BenchmarkId::new(format!("{readers}r"), runs), |b| { + b.iter_batched_ref(|| Fills::new(readers, runs), Fills::run, BatchSize::PerIteration); + }); + } + } + group.finish(); +} + criterion_group!( benches, bench_range_widening, @@ -1666,6 +1749,7 @@ criterion_group!( bench_gap_flush, bench_wall, bench_empty_run, - bench_time_end + bench_time_end, + bench_fill ); criterion_main!(benches); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 5a54502fbb1..1c23dadda02 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -29,16 +29,12 @@ use super::{Message, Version, cluster, error::request, peer, request_update}; mod tracks; -/// Largest millisecond duration every implementation can carry losslessly. -const MAX_SAFE_DELAY_MS: u64 = super::max_age::MAX_MILLIS; - /// Preserve retained groups when the subscriber supplies no age budget. fn serving_subscription(subscriber_priority: u8) -> Subscription { Subscription { priority: super::priority::from_wire(subscriber_priority), - // Demand can cross a Lite hop before the producer's retention bound is - // known, so use the largest duration that remains wire-encodable. - max_delay: Duration::from_millis(MAX_SAFE_DELAY_MS), + // No Max Age is the publisher's retention alone, whatever it turns out to be. + max_delay: None, ..Default::default() } } @@ -1404,7 +1400,7 @@ impl Publisher { // What an update changed while forwarding outlives the pause. let applied = track_serve.track.subscription(); msg.subscriber_priority = super::priority::to_wire(applied.priority); - msg.max_age = applied.max_delay.as_millis().try_into().ok(); + msg.max_age = applied.max_delay.and_then(|delay| delay.as_millis().try_into().ok()); track_serve.close_groups(); self.pause_join(_join.as_ref()); drop(track_serve); @@ -1777,7 +1773,7 @@ impl Publisher { // which the track's refresh ratio judges. let mut subscription = serving_subscription(msg.subscriber_priority).with_order(msg.group_order.to_order()); if let Some(max_age) = msg.max_age { - subscription.max_delay = Duration::from_millis(max_age); + subscription.max_delay = Some(Duration::from_millis(max_age)); } // A range that resolves without the live edge has a fixed start, which is no join: // bound the subscription before subscribing so the ratio never judges it. From the @@ -2056,7 +2052,7 @@ impl Publisher { } if let Some(max_age) = update.max_age { let mut subscription = serve.track.subscription(); - subscription.max_delay = Duration::from_millis(max_age); + subscription.max_delay = Some(Duration::from_millis(max_age)); serve.track.update(subscription).map_err(failed)?; } if let Some(value) = update.new_group { @@ -11080,7 +11076,7 @@ mod serve_tests { let request = h._broadcast.reserve_track("cam").unwrap(); let dynamic = request.dynamic(); let fetching = request.consume().fetch_group(4, None); - let mut fetched = dynamic.requested_group().await.unwrap().accept(None).unwrap(); + let mut fetched = dynamic.requested_fill_group().await.unwrap().accept(None).unwrap(); for object in 0..2 { fetched.write_frame(None, format!("4-{object}").into_bytes()).unwrap(); } @@ -14041,7 +14037,10 @@ mod tests { } let subscription = serving_subscription(128); - assert_eq!(subscription.max_delay.as_millis(), MAX_SAFE_DELAY_MS as u128); + assert_eq!( + subscription.max_delay, None, + "no Max Age is the publisher's retention alone" + ); let mut subscriber = producer.subscribe(subscription); for sequence in [0, 1] { let group = subscriber diff --git a/rs/moq-net/src/ietf/publisher/tracks.rs b/rs/moq-net/src/ietf/publisher/tracks.rs index 8aba90df202..2705bf40e6a 100644 --- a/rs/moq-net/src/ietf/publisher/tracks.rs +++ b/rs/moq-net/src/ietf/publisher/tracks.rs @@ -1147,7 +1147,7 @@ impl Publisher { subscription = subscription.with_order(order.to_order()); } if let Some(max_age) = params.max_age { - subscription.max_delay = Duration::from_millis(max_age); + subscription.max_delay = Some(Duration::from_millis(max_age)); } // A start the live edge does not decide is no join; see `serve_subscribe_stream`. let fixed = subscribe_range(params.filter, LiveEdge::default(), self.version); @@ -1277,7 +1277,9 @@ impl Publisher { // What an update changed while forwarding outlives a pause. let applied = serve.track.subscription(); params.priority = crate::ietf::priority::to_wire(applied.priority); - params.max_age = applied.max_delay.as_millis().try_into().ok(); + params.max_age = applied + .max_delay + .and_then(|max_delay| max_delay.as_millis().try_into().ok()); let served = match served { Ok(Some(served)) => served, Ok(None) => { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 6ceb76fc679..0442457b743 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -548,6 +548,8 @@ struct Idle { dynamic: track::Dynamic, demand: track::Demand, timescale: Option, + /// The publisher ended the track, so a returning subscriber reads the copy as it is. + finished: bool, } struct TrackState { @@ -2919,8 +2921,8 @@ impl Subscriber { } /// Subscribe upstream for `target`, until the subscription ends: `Some` once nobody - /// subscribes any more, with the copy kept for [`Self::linger`], `None` once the track - /// ended or failed. + /// subscribes any more or the track ended, with the copy kept for [`Self::linger`], + /// `None` once it failed. async fn subscribe_once( &mut self, broadcast_path: &Path<'_>, @@ -3073,14 +3075,8 @@ impl Subscriber { let max_age_supported = self.peer_setup.get().await.max_age; let aggregate = target.subscription(); let priority = aggregate.as_ref().map(|s| s.priority).unwrap_or(0); - let max_age = max_age_supported.then(|| { - super::max_age::millis( - aggregate - .as_ref() - .map(|s| s.max_delay) - .unwrap_or(Duration::from_millis(super::max_age::MAX_MILLIS)), - ) - }); + let max_age = + max_age_supported.then(|| super::max_age::millis(aggregate.as_ref().map_or(Duration::MAX, |s| s.budget()))); let order = aggregate.map(|s| s.order).unwrap_or_default(); // Without an Established subscription, a relay includes the request it holds. let new_group = target @@ -3460,7 +3456,7 @@ impl Subscriber { Idle, Revoked, Done(Result, Error>), - Fetch(group::Request), + Fill(track::Fill), Update(Update), } @@ -3492,6 +3488,8 @@ impl Subscriber { let mut failed = false; // How the upstream subscription ended, which ends a listing entry it held. let mut ended: Option> = None; + // The publisher ended the track cleanly: the copy lingers for its history's fills. + let mut finished = false; let mut demand = track.demand(); // Nobody subscribing at all (only fetches asked) needs no subscription. let mut subscribed = track.subscription().is_some(); @@ -3517,8 +3515,8 @@ impl Subscriber { } } // An error is the track closing, which the arms below report. - if let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(waiter) { - return Poll::Ready(End::Fetch(request)); + if let Poll::Ready(Ok(requested)) = dynamic.poll_requested_fill(waiter) { + return Poll::Ready(End::Fill(requested)); } let _ = group_fetches.poll(waiter); // A group past the answer's Largest also makes the copy current, so a join @@ -3537,7 +3535,7 @@ impl Subscriber { if let Some(subscription) = subscription { priority = subscription.priority; if max_age_supported { - wanted_max_age = Some(super::max_age::millis(subscription.max_delay)); + wanted_max_age = Some(super::max_age::millis(subscription.budget())); } } } @@ -3615,11 +3613,11 @@ impl Subscriber { ended = Some(Err(Error::Unauthorized)); break true; } - End::Fetch(request) => { - let fetch = self.clone().run_group_fetch( + End::Fill(requested) => { + let fetch = self.clone().serve_fill( broadcast_path.to_owned(), track_name.to_owned(), - request, + requested, timescale, ); group_fetches.push(fetch); @@ -3674,6 +3672,9 @@ impl Subscriber { // A no-op once an END_OF_TRACK declared the end, whose readers wait // for a missing group only as long as the grace. let _ = track.finish(); + // Only TRACK_ENDED ends the track: after SUBSCRIPTION_ENDED a + // later reader subscribes afresh. + finished = matches!(ended, Some(Ok(()))); } Err(err) => { tracing::debug!(%err, "subscribe ended with error"); @@ -3710,6 +3711,7 @@ impl Subscriber { track, dynamic, timescale, + finished: false, }); } @@ -3719,7 +3721,15 @@ impl Subscriber { if !idle { // The publisher already ended the request, so a FIN is all we owe it. stream.writer.finish().ok(); - return None; + // A reader may still miss history the publisher holds, such as a group whose + // fetch was in flight as the track ended, so a finished copy keeps serving fills. + return (finished && !aborted).then(|| Idle { + demand: track.demand(), + track, + dynamic, + timescale, + finished: true, + }); } // What the copy cached goes stale from here. Marked before the cancel, which waits // on the publisher: it stops serving as soon as the cancel lands, and a reader @@ -3746,12 +3756,13 @@ impl Subscriber { track, dynamic, timescale, + finished: false, }) } /// Keep a copy nobody subscribes to, cache and all, for fetches and a subscriber /// returning soon: `Some` once one does, to subscribe again, `None` once nobody held - /// it through the linger, or the session ended. + /// it through the linger, or the session ended. A finished copy never subscribes again. async fn linger( &mut self, broadcast_path: &Path<'_>, @@ -3771,7 +3782,7 @@ impl Subscriber { let mut linger = crate::time::Deadline::new(&self.runtime); let mut subscribed = idle.track.subscription().is_some(); enum Step { - Fetch(group::Request), + Fill(track::Fill), Subscribe, Expired, Revoked, @@ -3785,14 +3796,14 @@ impl Subscriber { if gate.poll_denied(waiter).is_ready() { return Poll::Ready(Step::Revoked); } - if let Poll::Ready(Ok(request)) = idle.dynamic.poll_requested_group(waiter) { - return Poll::Ready(Step::Fetch(request)); + if let Poll::Ready(Ok(fill)) = idle.dynamic.poll_requested_fill(waiter) { + return Poll::Ready(Step::Fill(fill)); } let _ = group_fetches.poll(waiter); while let Poll::Ready(Ok(subscription)) = idle.track.poll_subscription_changed(waiter) { subscribed = subscription.is_some(); } - if subscribed { + if subscribed && !idle.finished { return Poll::Ready(Step::Subscribe); } // Nobody holds it: let it go after the linger. A reader waiting on a fetch holds @@ -3812,13 +3823,10 @@ impl Subscriber { .await; match step { - Step::Fetch(request) => { - let fetch = self.clone().run_group_fetch( - broadcast_path.to_owned(), - track_name.to_owned(), - request, - idle.timescale, - ); + Step::Fill(fill) => { + let fetch = + self.clone() + .serve_fill(broadcast_path.to_owned(), track_name.to_owned(), fill, idle.timescale); group_fetches.push(fetch); } Step::Subscribe => break true, @@ -4329,6 +4337,7 @@ impl Subscriber { track, dynamic, timescale: ok.properties.timescale, + finished: false, }) } @@ -5281,6 +5290,26 @@ impl Subscriber { Ok(Fetched::Frame) } + /// Serve a fill a group at a time, newest first for a descending reader, until it is + /// answered or nobody waits on it. + async fn serve_fill(self, broadcast: PathOwned, name: String, fill: track::Fill, timescale: Option) { + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + { + // Every fetch answers its group, so the same one coming back is a bug; ending the + // fill names it dropped rather than fetching it forever. + if last == Some(sequence) { + debug_assert!(false, "fill group {sequence} left unanswered"); + return; + } + last = Some(sequence); + self.clone() + .run_group_fetch(broadcast.clone(), name.clone(), fill.group(sequence), timescale) + .await; + } + } + /// Fetch a group from the publisher to fill a cache miss, with a standalone (or /// draft-20 filtered) FETCH for this subscription's track. It asks from the frame the reader wants through the end /// of the group, so a publisher that evicted the prefix can still answer. @@ -5292,7 +5321,7 @@ impl Subscriber { self, broadcast: PathOwned, name: String, - request: group::Request, + request: track::GroupFill, timescale: Option, ) { let validity = self.validity.start(); @@ -5305,7 +5334,7 @@ impl Subscriber { let mut opening = self.session.clone(); let mut stream = match kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = track::TrackState::poll_closed(&request.state, waiter) { + if let Poll::Ready(err) = request.poll_closed(waiter) { return Poll::Ready(Err(err)); } Stream::poll_open_request(&mut opening, self.version, &mut waiter.context()) @@ -5319,7 +5348,7 @@ impl Subscriber { let mut timeout = crate::time::Deadline::after(&self.runtime, Control::REQUEST_ID_TIMEOUT); let fetch_id = match kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = track::TrackState::poll_closed(&request.state, waiter) { + if let Poll::Ready(err) = request.poll_closed(waiter) { return Poll::Ready(Err(err)); } self.control.poll_credit(waiter, &mut timeout) @@ -5396,14 +5425,13 @@ impl Subscriber { if let Err(err) = validity.check(waiter) { return Poll::Ready(SetupResponse::Cancelled(err)); } - if let Poll::Ready(err) = track::TrackState::poll_closed(&request.state, waiter) { + if let Poll::Ready(err) = request.poll_closed(waiter) { return Poll::Ready(SetupResponse::Cancelled(err)); } if let Poll::Ready(res) = response { return Poll::Ready(SetupResponse::Response(res)); } request - .demand() .poll_unused(waiter) .map(|_| SetupResponse::Cancelled(Error::Cancel)) }) @@ -5479,10 +5507,10 @@ impl Subscriber { // stream also owes every object up to it. A whole-group answer has no object bound. let end = (end.group == sequence && !whole_group).then_some(end.object); - // Joined fetches still count until they pick the accepted group up from the cache. - let joined = request.result.clone(); + // The group's readers still count until they pick it up from the cache. + let readers = request.readers(); // The track was accepted before any fetch was asked, so it keeps that info. - let track = request.state.clone(); + let track = request.track(); let mut producer = match request.accept(None) { Ok(producer) => producer, // Already served by a concurrent fetch, or the track closed. @@ -5543,7 +5571,7 @@ impl Subscriber { slot.poll(waiter, |state| match &**state { GroupFetch::Done => Poll::Ready(()), - _ if joined.poll_unused(waiter).is_ready() + _ if readers.poll_unused(waiter).is_ready() && demand.poll_unused(waiter).is_ready() && demand.abort_unused(Error::Cancel) => { @@ -7810,7 +7838,7 @@ mod tests { let dynamic = track.dynamic(); let mut pending = Box::pin(track.consume().fetch_group(0, None)); assert!(futures::poll!(pending.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("group request"); + let request = dynamic.requested_fill_group().await.expect("group request"); group = Some(pending); accepted = Some(track); Box::pin(subscriber.clone().run_group_fetch( @@ -8003,7 +8031,7 @@ mod tests { let requested = track.dynamic(); let mut pending = Box::pin(track.consume().fetch_group(0, None)); assert!(futures::poll!(pending.as_mut()).is_pending()); - let group = requested.requested_group().await.expect("group request"); + let group = requested.requested_fill_group().await.expect("group request"); fetch = Some((track, pending)); moq_clock::sim::spawn(subscriber.run_group_fetch( Path::new("broadcast").to_owned(), @@ -15788,8 +15816,8 @@ mod stitch_tests { let dynamic = self.track.dynamic(); let mut fetch = Box::pin(self.track.consume().fetch_group(SEQUENCE, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("group request"); - let track = request.state.clone(); + let request = dynamic.requested_fill_group().await.expect("group request"); + let track = request.track(); let producer = request.accept(None).unwrap(); let consumer = producer.consume(); let slot = kio::Producer::new(GroupFetch::Ready { @@ -16508,7 +16536,11 @@ mod joining_fetch_tests { assert_eq!(group.read_frame().await.unwrap().unwrap().payload.as_ref(), b"x"); // The grace runs out with the head still being written, ending the subscription. - (&mut run.serving).await.unwrap(); + // The finished copy lingers for fills while the reader holds it. + while !run.subscriber.state.lock().subscribes.is_empty() { + moq_clock::sleep(Duration::from_millis(1)).await; + } + assert!(!run.serving.is_finished(), "{version}: the finished copy lingers"); data.close(crate::lite::test_transport::Close::Fin); assert!( @@ -16520,6 +16552,33 @@ mod joining_fetch_tests { } } + /// SUBSCRIPTION_ENDED ends only our subscription, not the track, so the copy is released + /// for a later reader to subscribe afresh rather than lingering as if the track ended. + #[moq_clock::test] + async fn a_subscription_ended_copy_does_not_linger() { + for version in JOINING_DRAFTS { + let mut ok = subscribe_ok(version, None); + ok.extend(message_bytes( + ietf::PublishDone::ID, + &ietf::PublishDone { + request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) + .then_some(RequestId(1)), + status_code: 0x3, + stream_count: 0, + reason_phrase: "done".into(), + }, + version, + )); + let mut run = JoinRun::start(version, None, ok, Vec::new()).await; + let _subscriber = (&mut run._hold.2).await.expect("subscribed"); + while !run.subscriber.state.lock().subscribes.is_empty() { + settle().await; + } + settle().await; + assert!(run.serving.is_finished(), "{version}: the copy is released"); + } + } + #[derive(Clone, Copy, PartialEq, Eq)] enum FetchStage { Unanswered, @@ -16562,8 +16621,8 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = Box::pin(consumer.fetch_group(GROUP, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("group request"); - let outcome = request.result.clone(); + let request = dynamic.requested_fill_group().await.expect("group request"); + let outcome = request.joined(); let mut run = Box::pin(subscriber.clone().run_group_fetch( Path::new("broadcast").to_owned(), "video".into(), @@ -16584,7 +16643,7 @@ mod joining_fetch_tests { "{version:?}: unanswered FETCH stays alive" ); assert!(matches!( - outcome.read().rejected, + outcome.read().refused(GROUP).cloned(), Some(Error::Cancel | Error::Unroutable) )); } else { @@ -16805,7 +16864,7 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, None)); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); subscriber .clone() @@ -16872,7 +16931,7 @@ mod joining_fetch_tests { let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, group::Fetch::default().with_frame_start(START))); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); subscriber .clone() @@ -17012,7 +17071,7 @@ mod joining_fetch_tests { let consumer = track.consume(); let mut fetch = std::pin::pin!(consumer.fetch_group(GROUP, group::Fetch::default().with_frame_start(START))); assert!(futures::poll!(fetch.as_mut()).is_pending()); - let request = dynamic.requested_group().await.expect("no group requested"); + let request = dynamic.requested_fill_group().await.expect("no group requested"); let serving = moq_clock::sim::spawn(subscriber.clone().run_group_fetch( Path::new("broadcast").to_owned(), diff --git a/rs/moq-net/src/ledger.rs b/rs/moq-net/src/ledger.rs index 8229c9d8df0..8473dbc52a1 100644 --- a/rs/moq-net/src/ledger.rs +++ b/rs/moq-net/src/ledger.rs @@ -159,6 +159,21 @@ impl Ledger { .map(|(_, run)| run.end) } + /// The accounted runs overlapping `groups`, clipped to it, lowest first. + pub fn accounted(&self, groups: Range) -> impl Iterator> + '_ { + // From the run holding the first group, if one does. + let lower = self + .runs + .range(..=groups.start) + .next_back() + .filter(|(_, run)| groups.start < run.end) + .map_or(groups.start, |(&start, _)| start); + self.runs + .range(lower..) + .take_while(move |(start, _)| **start < groups.end) + .map(move |(&start, run)| start.max(groups.start)..run.end.min(groups.end)) + } + /// Whether every group in `groups` is accounted for. pub fn covers(&self, groups: Range) -> bool { groups.is_empty() || self.run_end(groups.start).is_some_and(|end| groups.end <= end) diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 86d4dc13d91..6e23ce09b33 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -93,20 +93,17 @@ struct Shared { } /// Largest millisecond duration every implementation can carry losslessly. -const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; +pub(super) const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; /// The budget to serve a peer with, given what its wire could tell us. /// /// A version without the field decodes as [`Duration::ZERO`], which is /// indistinguishable from a peer genuinely asking for the live edge. Serving that -/// as real time would discard backlog a legacy subscriber never declined, so fall -/// back to a window wide enough not to drop and leave enforcement to the receiver, -/// exactly as the IETF path does for the same reason. -fn serving_max_delay(version: Version, requested: Duration) -> Duration { - match version.carries_max_delay() { - true => requested, - false => Duration::from_millis(MAX_SAFE_DELAY_MS), - } +/// as real time would discard backlog a legacy subscriber never declined, so serve the +/// publisher's max age alone and leave enforcement to the receiver, exactly as the IETF +/// path does for the same reason. +fn serving_max_delay(version: Version, requested: Duration) -> Option { + version.carries_max_delay().then_some(requested) } /// The floor of a subscription's read cursor for the wire serving it. @@ -5269,17 +5266,14 @@ mod serve_group_test { fn a_version_without_the_field_serves_a_non_dropping_budget() { for version in [Version::Lite01, Version::Lite02] { let max_delay = serving_max_delay(version, Duration::ZERO); - assert!( - max_delay >= Duration::from_secs(86_400), - "{version:?} must not be served as real time: {max_delay:?}" - ); + assert_eq!(max_delay, None, "{version:?} must not be served as real time"); } // A version that does carry it is taken at its word, zero included. - assert_eq!(serving_max_delay(Version::Lite05, Duration::ZERO), Duration::ZERO); + assert_eq!(serving_max_delay(Version::Lite05, Duration::ZERO), Some(Duration::ZERO)); assert_eq!( serving_max_delay(Version::Lite05, Duration::from_secs(3)), - Duration::from_secs(3) + Some(Duration::from_secs(3)) ); } @@ -5738,9 +5732,9 @@ mod serve_group_test { track.finish().unwrap(); run.await.unwrap(); - // SUBSCRIBE_START at 0, SUBSCRIBE_END at 3, and a DROP for the skipped group 1 with - // NOT_FOUND (0x33). - assert_eq!(*log.writes.lock().unwrap(), [0, 3, 0, 1, 0, 1, 1, 3, 2, 3, 1, 1, 0x33]); + // SUBSCRIBE_START at 0, a DROP for the skipped group 1 with NOT_FOUND (0x33), which + // the end gives up on at once, and SUBSCRIBE_END at 3. + assert_eq!(*log.writes.lock().unwrap(), [0, 3, 0, 1, 0, 2, 3, 1, 1, 0x33, 1, 1, 3]); } /// A relay that holds only the first group of a backlog answers with the largest its @@ -6375,7 +6369,9 @@ mod serve_group_test { drop(track); run.await.unwrap(); } - assert_eq!(drops(&mut writer, &log, version).await, [(4, 4), (0, 1), (3, 3)]); + // The feed gave up on 0, 1 and 3 before the update, so they are misses named as the + // cursor judges them, ahead of the stale group. + assert_eq!(drops(&mut writer, &log, version).await, [(0, 1), (3, 3), (4, 4)]); } /// After a raised start, lowering it again restarts the gaps below the raised floor, not @@ -6422,7 +6418,8 @@ mod serve_group_test { track.finish_at(track::Position::group(6)).unwrap(); drop(track); kio::wait(|waiter| run.poll(&mut writer, waiter)).await.unwrap(); - assert_eq!(drops(&mut writer, &log, version).await, [(1, 4)]); + // The feed gives up on 2..5 once its grace passes, a miss nothing fills. + assert_eq!(drops(&mut writer, &log, version).await, [(2, 4), (1, 1)]); } /// A raised floor sends no early drop: a later update may lower it again, and the diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 3a7acad596b..aa1a938eabd 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2099,12 +2099,12 @@ mod tests { const VERSION: Version = Version::Lite05; /// A pre-lite-05 relay's track, untimed, with a fetch of `sequence` queued on it. - fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, group::Request) { + fn feed_fetch(sequence: u64) -> (track::Producer, kio::Pending, track::Fill) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", info); let dynamic = track.dynamic(); let fetching = track.consume().fetch_group(sequence, None); - let req = dynamic.requested_group().now_or_never().expect("queued").unwrap(); + let req = dynamic.requested_fill().now_or_never().expect("queued").unwrap(); (track, fetching, req) } @@ -2150,7 +2150,7 @@ mod tests { let (track, fetching, req) = feed_fetch(100); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 1, "held while its caller waits"); @@ -2171,7 +2171,7 @@ mod tests { arrive(&track, &tail, 0, start); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); arrive(&track, &tail, 2, start); let late = start + crate::tail::GRACE / 2; fetches.poll(&sub, late, &kio::Waiter::noop()); @@ -2200,7 +2200,7 @@ mod tests { arrive(&track, &tail, 2, start); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start + crate::tail::GRACE, &kio::Waiter::noop()); assert!(fetches.held.is_empty()); assert!(matches!(fetching.now_or_never(), Some(Err(Error::NotFound)))); @@ -2252,7 +2252,7 @@ mod tests { let now = crate::time::Instant::now(); let clock = crate::time::Deadlines::new(now); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, now, &kio::Waiter::noop()); assert_eq!( fetches.held.len(), @@ -2275,7 +2275,7 @@ mod tests { /// Many group fetches on one track share model state, but never share readiness. fn feed_batch( sequences: impl IntoIterator, - ) -> (track::Producer, Vec>, Vec) { + ) -> (track::Producer, Vec>, Vec) { let info = track::Info::default().with_timescale(None); let track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); let dynamic = track.dynamic(); @@ -2283,7 +2283,7 @@ mod tests { let mut requests = Vec::new(); for sequence in sequences { callers.push(track.consume().fetch_group(sequence, None)); - requests.push(dynamic.requested_group().now_or_never().expect("queued").unwrap()); + requests.push(dynamic.requested_fill().now_or_never().expect("queued").unwrap()); } (track, callers, requests) } @@ -2305,7 +2305,7 @@ mod tests { let wakes = Arc::new(Wakes::default()); let waiter = kio::Waiter::new(std::task::Waker::from(wakes.clone())); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &waiter); wakes.0.store(0, atomic::Ordering::Relaxed); drop(caller); @@ -2343,7 +2343,7 @@ mod tests { let mut fetches = FeedFetches::new(&clock); let (track, _callers, requests) = feed_batch(0..32); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2364,7 +2364,7 @@ mod tests { let (track, _callers, requests) = feed_batch([1, 3, 5, 1000]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } arrive(&track, &tail, 0, start); arrive(&track, &tail, 2, start); @@ -2392,7 +2392,7 @@ mod tests { let (mut track, _callers, requests) = feed_batch([1, 3, 5, 1000]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2421,7 +2421,7 @@ mod tests { let (track, mut callers, requests) = feed_batch([1, 3, 5]); let mut fetches = FeedFetches::new(&clock); for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } let waiter = kio::Waiter::noop(); fetches.poll(&sub, start, &waiter); @@ -2438,6 +2438,44 @@ mod tests { )); } + /// A peer that starts its feed far past a reader's floor makes the reader's miss wide. + /// Holding it settles what the feed gave up by run, in one poll, rather than holding or + /// refusing its groups one at a time. + #[moq_clock::test] + async fn a_wide_feed_fill_settles_by_run() { + const WIDE: u64 = 1 << 40; + let start = crate::time::Instant::now(); + let clock = crate::time::Deadlines::new(start); + let tail = kio::Producer::new(Tail::default()); + let sub = feed_sub(&tail).await; + let info = track::Info::default().with_timescale(None); + let mut track = track::Producer::new(Arc::new(crate::broadcast::Info::default()), "video", info); + let dynamic = track.dynamic(); + let mut reader = track.subscribe( + Subscription::default() + .with_range(0..) + .with_max_delay(Duration::from_secs(30)), + ); + track.start_at(WIDE).unwrap(); + arrive(&track, &tail, WIDE, start); + let waiter = kio::Waiter::noop(); + // Reading judges the reader's misses. + let _ = reader.poll_recv_group(&waiter); + let fill = dynamic.requested_fill().now_or_never().expect("queued").unwrap(); + assert_eq!((fill.start().group, fill.end()), (0, WIDE)); + + let mut fetches = FeedFetches::new(&clock); + fetches.push(fill, &track.consume()); + fetches.poll(&sub, start, &waiter); + assert!(fetches.held.is_empty(), "the feed starts past every group of it"); + assert_eq!(fetches.polled, 1); + assert!(reader.poll_recv_group(&waiter).is_pending()); + assert!( + dynamic.requested_fill().now_or_never().is_none(), + "a refusal is not asked again" + ); + } + #[moq_clock::test] async fn the_feed_track_backstop_refuses_the_next_group() { let start = crate::time::Instant::now(); @@ -2448,7 +2486,7 @@ mod tests { let mut fetches = FeedFetches::new(&clock); fetches.max = 2; for req in requests { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 2); @@ -2468,16 +2506,16 @@ mod tests { let (track, caller, req) = feed_fetch(1); let dynamic = track.dynamic(); let mut fetches = FeedFetches::new(&clock); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); drop(caller); let next = track.consume().fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill() .now_or_never() .expect("replacement queued") .unwrap(); - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); fetches.poll(&sub, start, &kio::Waiter::noop()); assert_eq!(fetches.held.len(), 1); arrive(&track, &tail, 1, start); @@ -2485,12 +2523,43 @@ mod tests { assert!(next.now_or_never().unwrap().is_ok()); } + /// A replacement takes the place of the attempt it replaces under the limit, before any + /// poll releases that one. + #[moq_clock::test] + async fn a_feed_fetch_replaces_an_abandoned_attempt_at_the_limit() { + let start = crate::time::Instant::now(); + let clock = crate::time::Deadlines::new(start); + let tail = kio::Producer::new(Tail::default()); + let sub = feed_sub(&tail).await; + let (track, mut callers, requests) = feed_batch([1, 2]); + let dynamic = track.dynamic(); + let mut fetches = FeedFetches::new(&clock); + fetches.max = 2; + for req in requests { + fetches.push(req, &track.consume()); + } + fetches.poll(&sub, start, &kio::Waiter::noop()); + drop(callers.remove(0)); + let mut next = track.consume().fetch_group(1, None); + let req = dynamic + .requested_fill() + .now_or_never() + .expect("replacement queued") + .unwrap(); + fetches.push(req, &track.consume()); + assert!((&mut next).now_or_never().is_none(), "held, not refused"); + assert_eq!(fetches.held.len(), 2); + arrive(&track, &tail, 1, start); + fetches.poll(&sub, start, &kio::Waiter::noop()); + assert!(next.now_or_never().unwrap().is_ok()); + } + /// Sweep held fetches, unrelated tracks, and live arrival runs independently. #[moq_clock::test] #[ignore = "release benchmark, run by nightly"] async fn benchmark_feed_fetch_wakes() { struct ScanFetches { - held: Vec<(group::Request, track::Consumer)>, + held: Vec<(track::GroupFill, track::Consumer)>, /// Armed at the earliest instant the feed's ledger gives up on a held group, so one /// that never arrives still fails. fold: crate::time::Deadline, @@ -2521,7 +2590,7 @@ mod tests { let tail = active.tail.read(); let mut fold: Option = None; for (req, feed) in std::mem::take(&mut self.held) { - if req.demand().poll_unused(waiter).is_ready() { + if req.poll_unused(waiter).is_ready() { continue; } let sequence = req.sequence(); @@ -2574,9 +2643,9 @@ mod tests { let mut fetches = FeedFetches::new(&clock); for req in requests { if indexed { - fetches.push(req, track.consume()); + fetches.push(req, &track.consume()); } else { - scan.held.push((req, track.consume())); + scan.held.push((req.group(req.start().group), track.consume())); } } if indexed { @@ -6364,7 +6433,7 @@ impl TrackServe { let start_moved = active.start != subscription.start(); active.priority = subscription.priority; active.order = subscription.order; - active.max_delay = subscription.max_delay; + active.max_delay = wire_max_delay(subscription.max_delay); // A lowered floor owes groups nobody asked for until now, in the session's // tail as well as in the track's arrivals, which its start declaration // restarts. @@ -6373,7 +6442,7 @@ impl TrackServe { start.group..floor.unwrap_or(u64::MAX) }); if let Ok(mut tail) = active.tail.write() { - tail.set_grace(tail::grace(subscription.max_delay)); + tail.set_grace(tail::grace(subscription.budget())); if let Some(groups) = lowered { tail.demand(groups, self.subscriber.runtime.now()); } @@ -6443,7 +6512,7 @@ impl TrackServe { tracing::info!(id, broadcast = %self.subscriber.log_path(&self.path), track = %self.name, "subscribe started"); - let tail = kio::Producer::new(Tail::new(tail::grace(subscription.max_delay))); + let tail = kio::Producer::new(Tail::new(tail::grace(subscription.budget()))); self.subscriber.subscribes.lock().insert( id, TrackEntry { @@ -6533,6 +6602,12 @@ enum Begin { Establish(Establish), } +/// The `Subscriber Max Age` a SUBSCRIBE carries for `max_delay`: `None` is the publisher's +/// max age alone, which the largest value every peer can read leaves to its clamp. +fn wire_max_delay(max_delay: Option) -> std::time::Duration { + max_delay.unwrap_or(std::time::Duration::from_millis(super::MAX_SAFE_DELAY_MS)) +} + /// Buffer the current SUBSCRIBE parameters with an updated end. The caller flushes. fn buffer_update(active: &mut SubStream, end: Option) -> Result<(), Error> { let bounds = WireBounds::new(active.start, end); @@ -6596,7 +6671,7 @@ impl Establish { broadcast: self.serve.path.as_path(), track: self.serve.name.as_str().into(), priority: self.subscription.priority, - max_delay: self.subscription.max_delay, + max_delay: wire_max_delay(self.subscription.max_delay), start_group: bounds.start_group, end_group: bounds.end_group, start_frame: bounds.start_frame, @@ -6650,7 +6725,7 @@ impl Establish { SubStream { stream, id: self.id, - max_delay: self.subscription.max_delay, + max_delay: wire_max_delay(self.subscription.max_delay), start: self.subscription.start(), priority: self.subscription.priority, order: self.subscription.order, @@ -7014,7 +7089,7 @@ struct ServeLoop { /// Serve on-demand fetches of uncached groups from this session. dynamic: track::Dynamic, sub: Sub, - fetches: kio::Tasks, + fetches: kio::Tasks, /// Fetches a pre-lite-05 upstream has no FETCH for, held for the live feed. feed_fetches: FeedFetches, // A dedicated close-watch handle for the session-died arm. @@ -7051,10 +7126,15 @@ enum ServeMode { }, } -/// Fetches a pre-lite-05 upstream has no FETCH for, held while the live subscription may -/// still deliver the group. A group the feed delivers lands in the cache, where a waiting -/// fetch reads it once its request is dropped. +/// Fills a pre-lite-05 upstream has no FETCH for, held while the live subscription may +/// still deliver their groups. A group the feed delivers lands in the cache, where the fill +/// answers it. +/// +/// A fill waits on one group at a time, the next it still owes. Each verdict on that group +/// also settles, by run, every group of the fill the feed gave up on, so a fill a peer +/// made wide costs what the cache and ledger hold rather than its width. struct FeedFetches { + /// Each fill, by the group it waits on. held: BTreeMap, ready: kio::Shared>, arrivals: Option, @@ -7067,8 +7147,10 @@ struct FeedFetches { } struct HeldFetch { - req: group::Request, + fill: track::Fill, waiter: kio::Waiter, + /// Not judged yet: what the feed already gave up on is settled at once. + fresh: bool, } struct FetchWake { @@ -7098,9 +7180,10 @@ impl FeedFetches { } } - fn push(&mut self, req: group::Request, feed: track::Consumer) { + /// Hold `fill` for the live feed. + fn push(&mut self, fill: track::Fill, feed: &track::Consumer) { let Some(arrivals) = feed.arrivals() else { - req.reject(Error::NotFound); + fill.drop_groups(fill.start().group..fill.end()); return; }; if let Some(previous) = &self.arrivals { @@ -7108,28 +7191,47 @@ impl FeedFetches { } else { self.arrivals = Some(arrivals); } - let sequence = req.sequence(); - // The last caller may leave and a replacement attempt arrive before the - // queued cancellation gets polled. The old attempt owns no callers now. - if let Some(previous) = self.held.remove(&sequence) { - debug_assert!(previous.req.demand().poll_unused(&kio::Waiter::noop()).is_ready()); - } - if self.held.len() >= self.max { - req.reject(Error::TooManyRequests); - return; + self.hold(fill, true); + } + + /// Wait on the next group `fill` still owes, if any. A fresh fill counts against the + /// limit, once what nobody waits on any more is released. + fn hold(&mut self, fill: track::Fill, fresh: bool) { + let mut next = Some((fill, fresh)); + while let Some((fill, fresh)) = next.take() { + let Some(sequence) = fill.next() else { + continue; + }; + // A replacement attempt may arrive before the poll that would see the held one's + // readers leave this group: that one moves on to the next group it owes, if any. + if let Some(previous) = self.held.remove(&sequence) + && previous.fill.is_used() + { + next = Some((previous.fill, false)); + } + if fresh { + let moving = usize::from(next.is_some()); + if self.held.len() + moving >= self.max { + self.held.retain(|_, held| held.fill.is_used()); + } + if self.held.len() + moving >= self.max { + fill.refuse(Error::TooManyRequests); + continue; + } + } + let waiter = kio::Waiter::new(std::task::Waker::from(Arc::new(FetchWake { + sequence, + ready: self.ready.clone(), + }))); + self.held.insert(sequence, HeldFetch { fill, waiter, fresh }); + self.ready.lock().insert(sequence); } - let waiter = kio::Waiter::new(std::task::Waker::from(Arc::new(FetchWake { - sequence, - ready: self.ready.clone(), - }))); - self.held.insert(sequence, HeldFetch { req, waiter }); - self.ready.lock().insert(sequence); } fn poll(&mut self, sub: &Sub, now: crate::time::Instant, waiter: &kio::Waiter) { let Sub::Active(_) = sub else { for (_, held) in std::mem::take(&mut self.held) { - held.req.reject(Error::NotFound); + held.fill.drop_groups(held.fill.start().group..held.fill.end()); } let mut ready = self.ready.lock(); if !ready.is_empty() { @@ -7170,38 +7272,60 @@ impl FeedFetches { Poll::Pending => BTreeSet::new(), }; let _ = self.ready.poll(waiter, |_| Poll::<()>::Pending); + let mut unused = Vec::new(); let mut settled = Vec::new(); for sequence in ready { - let Some(held) = self.held.remove(&sequence) else { + let Some(mut held) = self.held.remove(&sequence) else { continue; }; #[cfg(test)] { self.polled += 1; } - if held.req.demand().poll_unused(&held.waiter).is_ready() { - settled.push((held.req, None)); + if held.fill.poll_unused(&held.waiter).is_ready() { + unused.push(held.fill); continue; } - match arrivals.poll_group(sequence, now, &held.waiter) { - Poll::Ready(Ok(Some(_))) => settled.push((held.req, None)), - Poll::Ready(Ok(None)) => settled.push((held.req, Some(Error::NotFound))), - Poll::Ready(Err(err)) => settled.push((held.req, Some(err))), - Poll::Pending => { - self.held.insert(sequence, held); - } + let verdict = arrivals.poll_group(sequence, now, &held.waiter); + if verdict.is_pending() && !held.fresh { + self.held.insert(sequence, held); + continue; + } + let gone = arrivals.given_up(held.fill.start().group..held.fill.end(), now); + if verdict.is_pending() && gone.is_empty() { + held.fresh = false; + self.held.insert(sequence, held); + continue; } + // Never coming is a miss; a group given up keeps the error it was given up with. + let decided = match verdict { + Poll::Ready(res) => Some((sequence, res.err().unwrap_or(Error::NotFound))), + Poll::Pending => None, + }; + settled.push((held.fill, decided, gone)); } let fold = arrivals .folds() .filter_map(|(gap, at)| self.held.range(gap).next().map(|_| at)) .min(); - // Requests own track producers: releasing one takes the track lock too. + // Fills own track producers: answering or releasing one takes the track lock too. drop(arrivals); - for (req, err) in settled { - if let Some(err) = err { - req.reject(err); + drop(unused); + for (fill, decided, gone) in settled { + // The group waited on is settled, so the fill moves past it: one landed without + // the frames the fill starts from is no answer, and the cache serves what it has. + // Answered before the bulk give-up, which knows no reason, so a drop's own error + // reaches the fetch. + if let Some((sequence, err)) = decided + && fill.next() == Some(sequence) + { + fill.group(sequence).reject(err); + } + for groups in gone.iter() { + fill.drop_groups(groups.clone()); } + // Waits on the next group it owes, judged on the poll its readiness wakes. + self.hold(fill, false); } self.fold.set(fold); let _ = self.fold.poll(waiter); @@ -7316,16 +7440,17 @@ impl ServeLoop { // (1) Track demand: a fetch, a subscription change, or the origin // handing the track to another route. - // A fetch is cheap and one-shot, so serve it ahead of subscription churn. - match self.dynamic.poll_requested_group(waiter) { - Poll::Ready(Ok(req)) => { + // A fill is bounded and serves readers already waiting, so serve it ahead of + // subscription churn. + match self.dynamic.poll_requested_fill(waiter) { + Poll::Ready(Ok(fill)) => { if self.supports_fetch { self.fetches - .push(FetchServeRun::new(serve.clone(), req, self.serving.info().clone())); + .push(FillServeRun::new(serve.clone(), fill, self.serving.info().clone())); } else { // No FETCH on the wire: wait for the live feed, which a // subscription arriving alongside may still be opening. - self.feed_fetches.push(req, self.serving.consume()); + self.feed_fetches.push(fill, &self.serving.consume()); } continue; } @@ -7463,7 +7588,7 @@ impl ServeLoop { active.served = Some(start.group); // The groups the SUBSCRIBE asked for below it are not // waited for, whatever the demand asks later. One that - // still arrives is delivered. + // still arrives is delivered, so they are not dropped. if let Some(requested) = active.requested && let Ok(mut tail) = active.tail.write() { @@ -7518,7 +7643,7 @@ impl ServeLoop { // subscriber was willing to wait for a late group anyway. if let Ok(mut tail) = active.tail.write() { tail.set_grace(tail::grace( - subscription.map(|sub| sub.max_delay).unwrap_or_default(), + subscription.map(|sub| sub.budget()).unwrap_or_default(), )); tail.expire(serve.subscriber.runtime.now()); } @@ -7548,6 +7673,59 @@ impl ServeLoop { } } +/// Serves one fill a group at a time, newest first for a descending reader, each with its +/// own [`FetchServeRun`], until it is answered or nobody waits on it. +struct FillServeRun { + serve: TrackServe, + fill: track::Fill, + info: track::Info, + run: Option<(u64, FetchServeRun)>, +} + +impl FillServeRun { + fn new(serve: TrackServe, fill: track::Fill, info: track::Info) -> Self { + Self { + serve, + fill, + info, + run: None, + } + } +} + +impl kio::Task for FillServeRun { + type Output = (); + + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + loop { + let last = match &mut self.run { + Some((sequence, run)) => { + ready!(run.poll(waiter)); + let sequence = *sequence; + self.run = None; + Some(sequence) + } + None => None, + }; + // Withdrawn: nobody waits on what is left, so it records nothing. + if self.fill.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + let Some(sequence) = self.fill.next() else { + return Poll::Ready(()); + }; + // Every run answers its group, so the same one coming back is a bug; ending the + // fill names it dropped rather than fetching it forever. + if last == Some(sequence) { + debug_assert!(false, "fill group {sequence} left unanswered"); + return Poll::Ready(()); + } + let run = FetchServeRun::new(self.serve.clone(), self.fill.group(sequence), self.info.clone()); + self.run = Some((sequence, run)); + } + } +} + /// Serves one downstream fetch end-to-end on its own bidi stream: send FETCH, /// then fill the group from the bare FRAME messages that follow. The track's /// info comes from its TRACK_INFO (already known), and the group sequence @@ -7564,26 +7742,25 @@ struct FetchServeRun { enum FetchRunState { Open { - request: Option, + request: Option, }, Send { - request: Option, + request: Option, stream: Stream, frame_start: u64, }, /// Flushed; waiting for the publisher to answer before accepting. Answer { - request: Option, + request: Option, stream: Stream, frame_start: u64, }, Ingest { stream: Stream, - producer: crate::recv::Group, - // The accepted request's demand: fetches that joined it but have not yet - // picked the group up from the cache. - joined: kio::Producer, - // Boxed so the other states stay small; one allocation per fetch. + // Boxed, as is the ingest, so the other states stay small. + producer: Box, + // The accepted group's readers that have not yet picked it up from the cache. + readers: track::FillReaders, ingest: Box, }, Done, @@ -7591,7 +7768,7 @@ enum FetchRunState { impl FetchRunState { /// The downstream request, while it waits on the publisher's answer. - fn request(&self) -> Option<&group::Request> { + fn request(&self) -> Option<&track::GroupFill> { match self { Self::Open { request } | Self::Send { request, .. } | Self::Answer { request, .. } => request.as_ref(), Self::Ingest { .. } | Self::Done => None, @@ -7600,7 +7777,7 @@ impl FetchRunState { } impl FetchServeRun { - fn new(serve: TrackServe, request: group::Request, info: track::Info) -> Self { + fn new(serve: TrackServe, request: track::GroupFill, info: track::Info) -> Self { let session = serve.subscriber.session.clone(); let group = request.sequence(); Self { @@ -7679,7 +7856,7 @@ impl kio::Task for FetchServeRun { // A fetch nobody waits on any more is cancelled upstream, so the publisher stops // serving it (and a relay there releases its own FETCH). Ingest has its own check. if let Some(request) = self.state.request() - && request.demand().poll_unused(waiter).is_ready() + && request.poll_unused(waiter).is_ready() { tracing::debug!(track = %self.serve.name, group = self.group, "fetch abandoned"); if let FetchRunState::Send { stream, .. } | FetchRunState::Answer { stream, .. } = @@ -7810,9 +7987,9 @@ impl kio::Task for FetchServeRun { // (a fetch with no live subscription); otherwise the group inherits the // accepted info. let group_info = self.info.clone(); - // The joined fetches pick the group up from the cache only when next - // polled, so their demand outlives the request until then. - let joined = request.result.clone(); + // The group's readers pick it up from the cache only when next polled, + // so their demand outlives the request until then. + let readers = request.readers(); let mut producer = match request.accept(group_info) { Ok(producer) => producer, Err(err) => { @@ -7833,8 +8010,8 @@ impl kio::Task for FetchServeRun { self.state = FetchRunState::Ingest { stream, - producer: crate::recv::Group::new(producer), - joined, + producer: Box::new(crate::recv::Group::new(producer)), + readers, ingest: Box::new(FrameIngest::new( lite::Framing::new(&self.info, self.serve.subscriber.version), self.serve.subscriber.version, @@ -7846,15 +8023,16 @@ impl kio::Task for FetchServeRun { FetchRunState::Ingest { stream, producer, - joined, + readers, ingest, } => { let Poll::Ready(res) = ingest.poll(&mut stream.reader, producer, waiter) else { // Still short of its end, so completion always wins. Once nobody - // wants the rest (no joined fetch left to pick it up, no reader), + // wants the rest (no reader left to pick it up, none reading it), // cancel upstream and abort the truncated group, never caching it // as complete. The abort is atomic with a new reader arriving. - if joined.poll_unused(waiter).is_pending() || producer.handle().poll_unused(waiter).is_pending() + if readers.poll_unused(waiter).is_pending() + || producer.handle().poll_unused(waiter).is_pending() { return Poll::Pending; } diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index f1a8ef2512e..81c5e228bbd 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -2153,7 +2153,7 @@ mod test { let dynamic = request.dynamic(); let track = consumer.track("track1").unwrap(); let pending_fetch = track.fetch_group(0, None); - let fetch = dynamic.requested_group().await.unwrap(); + let fetch = dynamic.requested_fill_group().await.unwrap(); let mut group = fetch.accept(crate::track::Info::timed()).unwrap(); group.finish().unwrap(); pending_fetch.await.unwrap(); diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index d81618e75f9..38558412137 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -2167,7 +2167,7 @@ impl Handle { pub(crate) fn demand(&self) -> Demand { Demand { sequence: self.info.sequence, - state: DemandSource::Group(self.state.weak()), + state: self.state.weak(), } } @@ -2183,41 +2183,25 @@ impl Handle { } } -/// A cloneable, watch-only handle to a group's readers or pending fetch callers. +/// A cloneable, watch-only handle to a group's readers. /// -/// An accepted or dropped request ends waits with [`Error::Dropped`]; a rejected request keeps -/// its rejection reason. +/// A finished or dropped group ends waits with [`Error::Dropped`]; an aborted one keeps +/// its abort reason. #[derive(Clone)] pub struct Demand { sequence: u64, - state: DemandSource, -} - -#[derive(Clone)] -enum DemandSource { - Group(kio::ProducerWeak), - Fetch(kio::ProducerWeak), + state: kio::ProducerWeak, } impl Demand { - pub(super) fn fetch(sequence: u64, state: kio::ProducerWeak) -> Self { - Self { - sequence, - state: DemandSource::Fetch(state), - } - } - /// The group sequence this handle watches. pub fn sequence(&self) -> u64 { self.sequence } - /// Whether the group or fetch is open and has readers right now. + /// Whether the group is open and has readers right now. pub fn is_used(&self) -> bool { - match &self.state { - DemandSource::Group(state) => !state.is_closed() && state.is_used(), - DemandSource::Fetch(state) => !state.is_closed() && state.is_used(), - } + !self.state.is_closed() && self.state.is_used() } /// Wait until at least one reader needs the group. @@ -2234,38 +2218,23 @@ impl Demand { /// Poll until at least one reader needs the group. pub fn poll_used(&self, waiter: &kio::Waiter) -> Poll> { - match &self.state { - DemandSource::Group(state) => state.poll_used(waiter), - DemandSource::Fetch(state) => state.poll_used(waiter), - } - .map_err(|_| self.abort_reason()) + self.state.poll_used(waiter).map_err(|_| self.abort_reason()) } /// Poll until no reader needs the group. pub fn poll_unused(&self, waiter: &kio::Waiter) -> Poll> { - match &self.state { - DemandSource::Group(state) => state.poll_unused(waiter), - DemandSource::Fetch(state) => state.poll_unused(waiter), - } - .map_err(|_| self.abort_reason()) + self.state.poll_unused(waiter).map_err(|_| self.abort_reason()) } - /// Wait until the group or fetch closes, returning its cause. + /// Wait until the group closes, returning its cause. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { - match &self.state { - DemandSource::Group(state) => state.closed().await, - DemandSource::Fetch(state) => state.closed().await, - } + self.state.closed().await; self.abort_reason() } fn abort_reason(&self) -> Error { - match &self.state { - DemandSource::Group(state) => state.read().abort.clone(), - DemandSource::Fetch(state) => state.read().rejected.clone(), - } - .unwrap_or(Error::Dropped) + self.state.read().abort.clone().unwrap_or(Error::Dropped) } } @@ -3253,27 +3222,6 @@ impl Fetch { } } -/// A consumer's request for a single past group, handed to a handler via -/// [`track::Dynamic::poll_requested_group`]. -/// -/// The handler fulfills it by calling [`Self::accept`], which inserts the group -/// into the track cache (resolving every [`track::Consumer::fetch_group`] that joined the -/// attempt) and returns a [`Producer`] to fill. A relay typically opens a wire -/// FETCH and waits for the publisher to answer before accepting, so a group the -/// publisher lacks is rejected rather than accepted and then aborted. The request -/// carries its own producer handle, so it works the same whether or not the track -/// has been accepted yet. -pub struct Request { - pub(crate) state: kio::Producer, - pub(super) fetch: kio::Shared, - pub(super) sequence: u64, - pub(super) priority: i8, - pub(super) max_delay: Option, - pub(super) frame_start: u64, - pub(crate) result: kio::Producer, - pub(super) done: bool, -} - #[cfg(test)] mod test { use super::*; diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 4e34cc8dec6..7fcba190585 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -9459,7 +9459,7 @@ mod tests { let fetched = Arc::new(crate::sync::Mutex::new(Vec::::new())); let serving = fetched.clone(); let _fetching = moq_clock::sim::spawn(async move { - while let Ok(request) = fetches.requested_group().await { + while let Ok(request) = fetches.requested_fill_group().await { let mut group = request.accept(crate::track::Info::timed()).unwrap(); for frame in [b"a", b"b", b"c"] { group.write_frame(crate::Timestamp::ZERO, frame.as_ref()).unwrap(); diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 811e1193f4a..c596815b403 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -485,7 +485,7 @@ impl Copy { { self.since = Some(self.track.now()); } - let budget = reader.mirrored.max_delay; + let budget = reader.mirrored.budget(); let grace = ledger::grace(budget); // What is under the bound is past the reader's budget a full budget after a newer // group superseded it, landed or not. @@ -1283,7 +1283,7 @@ impl Reader { } self.groups_read = true; let groups = self.groups; - let budget = self.mirrored.max_delay; + let budget = self.mirrored.budget(); let floor = if ordered { delivered.floor() } else { Some(0) }; let mut lowest: Option = None; for index in 0..self.copies.len() { @@ -1450,7 +1450,7 @@ impl Reader { } self.let_go(delivered, waiter); let at = Instant::now(); - self.datagrams.expire(at, self.mirrored.max_delay); + self.datagrams.expire(at, self.mirrored.budget()); let groups = self.groups; for index in (0..self.copies.len()).rev() { loop { @@ -1878,7 +1878,7 @@ impl Recover { // A held group or frame may be the only thing being polled. Mirror and watch // preferences here too, before judging its budget against the live edge. reader.sync(waiter); - Some(reader.mirrored.max_delay) + Some(reader.mirrored.budget()) } /// Commit the replacement only once the caller has acquired its cursor or frame. @@ -2069,7 +2069,7 @@ mod test { a.abort(Error::Stream(StreamError::Session(SessionError::Cancel))) .unwrap(); assert!(held.poll_read_frame(&waiter).is_pending()); - let Poll::Ready(request) = dynamic.poll_requested_group(&waiter) else { + let Poll::Ready(request) = dynamic.poll_requested_fill_group(&waiter) else { panic!("recovery fetch") }; request.unwrap().reject(Error::NotFound); @@ -2511,7 +2511,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 2)); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(2), frames[2].clone()).unwrap(); @@ -2590,7 +2590,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(request.frame_start(), 1); // Shrink the pool so the next write reclaims the held head. @@ -2672,7 +2672,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none(), "fails={fails}"); - let _pending = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let _pending = dynamic.requested_fill().now_or_never().unwrap().unwrap(); b.drop_groups(0..1, StreamError::App(5)); let res = reading.read_frame().now_or_never(); @@ -2705,7 +2705,7 @@ mod test { routes.serve(b.consume()); b.drop_groups(0..1, StreamError::Old); assert!(fetched.read_frame().now_or_never().is_none()); - let _pending = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let _pending = dynamic.requested_fill().now_or_never().unwrap().unwrap(); assert!(fetched.read_frame().now_or_never().is_none()); } @@ -2748,7 +2748,7 @@ mod test { routes.serve(b.consume()); assert_eq!(read(&mut reading), Some(b"b".to_vec())); assert!(reading.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 2)); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(2), b"c".as_ref()).unwrap(); @@ -2759,7 +2759,10 @@ mod test { let whole = [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]; assert_eq!(read_cached(&b.consume(), 0), whole); assert_eq!(read_cached(&logical.consume(), 0), whole); - assert!(dynamic.requested_group().now_or_never().is_none(), "no second fetch"); + assert!( + dynamic.requested_fill_group().now_or_never().is_none(), + "no second fetch" + ); } /// A replaced route that only stalled lends the replacement its head the same way. @@ -2781,7 +2784,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(reading.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(request.frame_start(), 1); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -2814,7 +2817,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(request.frame_start(), 1); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -2823,7 +2826,7 @@ mod test { assert_eq!(read(&mut fetched), None); let _whole = b.consume().fetch_group(0, None); assert!( - dynamic.requested_group().now_or_never().is_some(), + dynamic.requested_fill_group().now_or_never().is_some(), "the head was not kept" ); } @@ -4688,7 +4691,7 @@ mod test { let wake = Arc::new(Wake::default()); let waiter = kio::Waiter::new(wake.clone().into()); assert!(kio::Task::poll(&mut *fetching, &waiter).is_pending()); - let _request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let _request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); wake.0.store(false, std::sync::atomic::Ordering::Relaxed); let b = copy(); let mut group = b.create_group(group::Info { sequence: 0 }).unwrap(); @@ -5001,7 +5004,7 @@ mod test { arriving.abort(Error::Stream(StreamError::Old)).unwrap(); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 1)); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); @@ -5058,7 +5061,7 @@ mod test { ); } assert!( - dynamic.requested_group().now_or_never().is_none(), + dynamic.requested_fill_group().now_or_never().is_none(), "{err:?} fetched={fetched}: asked its own route again" ); } @@ -5099,11 +5102,11 @@ mod test { assert!(matches!(res, Some(Err(Error::WrongSize))), "fetched={fetched}: {res:?}"); } assert!( - a_requests.requested_group().now_or_never().is_none(), + a_requests.requested_fill_group().now_or_never().is_none(), "fetched={fetched}" ); assert!( - b_requests.requested_group().now_or_never().is_none(), + b_requests.requested_fill_group().now_or_never().is_none(), "fetched={fetched}" ); } @@ -5129,7 +5132,7 @@ mod test { routes.serve(b.consume()); open.abort(Error::Stream(code.clone())).unwrap(); assert!(reading.read_frame().now_or_never().is_none(), "{code:?}"); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); let mut rest = request.accept(None).unwrap(); rest.write_frame(ts(1), b"b".as_ref()).unwrap(); rest.finish().unwrap(); @@ -5185,7 +5188,7 @@ mod test { let dynamic = b.dynamic(); routes.serve(b.consume()); assert!(fetched.read_frame().now_or_never().is_none()); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let request = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!((request.frame_start(), request.max_delay()), (1, Some(budget))); // The new route runs a full second past the group's successor while its fetch is unanswered. @@ -5760,7 +5763,7 @@ mod test { ); let demand = copies[0].subscription().expect("surviving reader"); assert_eq!(demand.priority, 7, "{events:?}"); - assert_eq!(demand.max_delay, Duration::from_secs(2), "{events:?}"); + assert_eq!(demand.max_delay, Some(Duration::from_secs(2)), "{events:?}"); drop(groups); drop(subscriptions); drop(control); diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index 85c7bccf93a..f94d88aac45 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -20,18 +20,22 @@ pub struct Subscription { /// Delivery priority, higher first, defaulting to 0. Higher values preempt lower ones /// when bandwidth is constrained. pub priority: i8, - /// How long a superseded group stays worth delivering to this subscriber. + /// How long a superseded group stays worth delivering to this subscriber, or `None` + /// for as long as the publisher keeps it. /// - /// [`Duration::ZERO`] (the default) skips immediately: group 8 arriving means group 7 - /// is abandoned, timed or not. A larger budget tolerates that much reordering before giving up. - /// This never *adds* delay, since the bound is only reached once newer data is - /// already that far ahead. + /// `Some(Duration::ZERO)` (the default) skips immediately: group 8 arriving means group + /// 7 is abandoned, timed or not. A larger budget tolerates that much reordering before + /// giving up. This never *adds* delay, since the bound is only reached once newer data + /// is already that far ahead. `None` is the publisher's + /// [`Info::max_age`](crate::track::Info::max_age) alone, what a reader reaching back + /// for history wants. /// /// This is the `Subscriber Max Age` on the wire, and it is stored here verbatim so /// what was asked for stays readable. Encoded as milliseconds in a QUIC varint, so - /// a duration of `2^62` milliseconds or more cannot be put on the wire. Clamped to - /// the publisher's [`Info::max_age`](crate::track::Info::max_age), since waiting for - /// a group longer than it is kept around cannot produce it. + /// a duration of `2^62` milliseconds or more cannot be put on the wire; `None` goes out + /// as the largest value every peer can read. Clamped to the publisher's + /// [`Info::max_age`](crate::track::Info::max_age), since waiting for a group longer + /// than it is kept around cannot produce it. /// /// It caps every one of [`Self::ranges`]: a range names what may be delivered, and this /// budget skips what inside it has gone stale. @@ -68,7 +72,7 @@ pub struct Subscription { /// when something collects the track's cache pool: an origin's driver does, and a /// standalone track needs [`Pool::poll`](crate::cache::Pool::poll) called, as its idle /// expiry already does. Otherwise it is judged only when something else wakes the read. - pub max_delay: Duration, + pub max_delay: Option, /// The spans of the track to deliver; see [`Range`]. /// /// The default is [`Range::oldest`]: everything [`Self::max_delay`] still considers @@ -277,7 +281,7 @@ impl Default for Subscription { fn default() -> Self { Self { priority: 0, - max_delay: Duration::ZERO, + max_delay: Some(Duration::ZERO), ranges: vec![Range::oldest()], order: Order::Descending, } @@ -291,13 +295,18 @@ impl Subscription { self } - /// Set how far a group may fall behind the live edge before it is skipped, returning - /// `self` for chaining. - pub fn with_max_delay(mut self, max_delay: Duration) -> Self { - self.max_delay = max_delay; + /// Set how far a group may fall behind the live edge before it is skipped, `None` for + /// the publisher's max age alone, returning `self` for chaining. + pub fn with_max_delay(mut self, max_delay: impl Into>) -> Self { + self.max_delay = max_delay.into(); self } + /// [`Self::max_delay`] as a budget: `None` waits as long as anything could. + pub(crate) fn budget(&self) -> Duration { + self.max_delay.unwrap_or(Duration::MAX) + } + /// Replace [`Self::ranges`], returning `self` for chaining. pub fn with_ranges(mut self, ranges: impl IntoIterator) -> Self { self.ranges = ranges.into_iter().collect(); @@ -320,7 +329,6 @@ impl Subscription { /// from the latest group (or none is asked for). /// /// The form a wire without ranges carries: a floor below which nothing is requested. - #[cfg(any(feature = "async", test))] pub(crate) fn start(&self) -> Option { let mut floor: Option = None; for range in &self.ranges { @@ -456,7 +464,10 @@ impl Combined { return; }; combined.priority = combined.priority.max(sub.priority); - combined.max_delay = combined.max_delay.max(sub.max_delay); + combined.max_delay = match (combined.max_delay, sub.max_delay) { + (Some(a), Some(b)) => Some(a.max(b)), + _ => None, + }; combined.ranges.extend_from_slice(&sub.ranges); // Sequence-first prioritization is enabled only when every subscriber wants it. if let Order::Descending = sub.order { diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 019ccdf77ac..e6bcb6ba114 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -118,6 +118,8 @@ pub(in crate::model) struct Window { spans: Vec, /// The groups the spans cover, an unsettled time end through its cap. groups: RangeInclusiveSet, + /// The part of `groups` a miss may ask for: see [`Self::missable`]. + missable: RangeInclusiveSet, /// A relative anchor waits on a first group, so nothing is filtered yet. pending: bool, /// Nothing is left to resolve: see [`Self::is_settled`]. Kept so a read checks it once @@ -134,6 +136,9 @@ struct Span { end: End, /// Whether the start names a group, rather than reaching back to the oldest one. floored: bool, + /// The lowest group a miss asks for. A time start before every group held asks none + /// below the oldest: which of them it reaches is unknown until they start. + reach: u64, } impl Span { @@ -324,6 +329,7 @@ impl Window { ranges: ranges.to_vec(), spans: Vec::new(), groups: RangeInclusiveSet::new(), + missable: RangeInclusiveSet::new(), pending: false, settled: false, }; @@ -483,9 +489,23 @@ impl Window { /// The groups in range from `from` on, lowest first. pub fn runs(&self, from: u64) -> impl Iterator> + '_ { + self.from(&self.groups, from) + } + + /// The groups in range from `from` on that a miss may ask for, lowest first: all of + /// them but those below the oldest group held when a time start fell before it. + pub fn missable(&self, from: u64) -> impl Iterator> + '_ { + self.from(&self.missable, from) + } + + fn from<'a>( + &'a self, + groups: &'a RangeInclusiveSet, + from: u64, + ) -> impl Iterator> + 'a { let all = self.pending.then_some(from..=u64::MAX); let runs = (!self.pending).then(|| { - self.groups + groups .overlapping(from..=u64::MAX) .map(move |run| (*run.start()).max(from)..=*run.end()) }); @@ -514,6 +534,17 @@ impl Window { hull.unwrap_or((0, None)) } + /// The groups some range starts inside, past their frame 0, that are read from there. + pub fn partial_starts(&self) -> impl Iterator + '_ { + self.ranges + .iter() + .filter_map(|range| match range.start { + Anchor::Absolute(start) if start.frame > 0 => Some(start.group), + _ => None, + }) + .filter(|group| self.frames(*group).0 > 0) + } + /// The first run of `range` that is in range. pub fn clip(&self, range: ops::Range) -> Option> { let last = range.end.checked_sub(1).filter(|last| *last >= range.start)?; @@ -556,11 +587,16 @@ impl Window { } end => end, }; + let reach = match range.start { + Anchor::Time(_) if !floored => holdings.next_start(0).map_or(start, |(oldest, _)| oldest), + _ => start, + }; self.spans.push(Span { index, start, end, floored, + reach, }); } self.rebuild(); @@ -570,6 +606,7 @@ impl Window { fn rebuild(&mut self) { self.settled = !self.pending && !self.spans.iter().any(|span| matches!(span.end, End::Time(_))); self.groups = RangeInclusiveSet::new(); + self.missable = RangeInclusiveSet::new(); for span in &self.spans { let last = match span.limit() { Some(end) => match end.checked_sub(1) { @@ -579,6 +616,9 @@ impl Window { None => u64::MAX, }; self.groups.insert(span.start..=last); + if span.reach <= last { + self.missable.insert(span.reach..=last); + } } } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index e955ac46f15..ac06a660396 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -17,8 +17,8 @@ use crate::sync::Mutex; use crate::{Error, Result, SessionError, StreamError, Timescale, Timestamp, coding}; use crate::{broadcast, cache, group, stats}; +use super::Datagram; use super::delivered::Delivered; -use super::{Datagram, Requests}; use crate::ledger::{self, Ledger}; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; @@ -29,6 +29,14 @@ mod demand; pub(super) use demand::Preferences; use demand::Subscriptions; +mod fill; +mod miss; +pub use fill::Fill; +use fill::FillState; + +#[cfg_attr(not(feature = "async"), allow(unused_imports, reason = "async-only"))] +pub(crate) use fill::{GroupFill, Readers as FillReaders}; + use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, ops::{self, Bound, RangeBounds}, @@ -507,9 +515,9 @@ pub(crate) struct TrackState { // Kept here (rather than threaded through every handle) so any holder reaches it. subscriptions: kio::Lock, - // The reverse fetch queue (see [`FetchState`]), same reasoning: cache-miss - // `fetch_group` calls enqueue here and a `Dynamic` drains. - fetch: kio::Shared, + // The fills readers queued for groups they miss (see [`Fill`]), same reasoning: a miss + // queues here and a `Dynamic` drains. + fills: kio::Shared, // A front's logical track: read straight from its routes' copies rather than this // cache, which stays empty; see [`super::resume`]. @@ -720,43 +728,6 @@ impl Slot { pub(super) const CACHE_OVERHEAD: u64 = 2 * (size_of::() + size_of::() + size_of::<(u64, Timestamp)>() + 2 * size_of::<(u64, u32)>()) as u64; -/// Reverse state for [`Consumer::fetch_group`], beside the track state in its own -/// [`kio::Shared`]: consumers enqueue (coalescing per sequence, so a relay opens one -/// upstream FETCH per group) and [`Dynamic`] handlers drain under one lock, without -/// write access to the track itself. -pub(super) type FetchState = Requests; - -/// One fetch attempt for a sequence, shared by every [`Fetching`] that joined it. -pub(super) struct PendingFetch { - // The most demanding delivery priority across the joined fetches. - priority: i8, - - // The most tolerant reader budget. None is unbounded and therefore dominates. - max_delay: Option, - - // The lowest start across the joined fetches, so serving the attempt once satisfies - // every one of them. Only widened while the request is still queued: once a handler - // has taken it, its range is already on the wire. - frame_start: u64, - - // Result channel for joined fetches. An accept resolves covered callers through - // the track cache and refuses uncovered ones. Dropping every producer closes - // an unserved attempt, which a [`Fetching`] reads as [`Error::NotFound`]. - result: kio::Producer, -} - -/// Whether a fetch attempt was accepted, or why it was rejected. -#[derive(Default)] -pub(crate) struct FetchOutcome { - pub(crate) rejected: Option, - // The budget the rejected attempt asked its handler for, so a looser joiner can tell - // that an `Old` refusal was not its own. - asked: Option, - // An accept still refuses joined fetches the cached group cannot cover, without - // giving the request's demand watcher a rejection reason. - accepted: bool, -} - /// Where the groups this hop holds start, for resolving time anchors: ordered lookups in /// the starts index and the cache, so no query walks more than the run it answers. impl Holdings for TrackState { @@ -1264,8 +1235,9 @@ impl TrackState { } /// The first cached group at or above `from` that `servable` covers for which `f` - /// answers, lowest first or, with `rev`, highest first: one lookup for a single run, - /// one per run otherwise. + /// answers, lowest first or, with `rev`, highest first: one lookup for a single run. + /// Several runs leapfrog the cache, so a run holding nothing (a reader's misses) costs + /// one lookup rather than a walk over every run past it. fn find_servable<'a, R>( &'a self, servable: &Servable, @@ -1288,13 +1260,31 @@ impl TrackState { scan(self.lookup.range((Bound::Included(from), end))) } Servable::Runs(runs) => { - let mut runs = runs.overlapping(from..=u64::MAX); - let each = - |run: &ops::RangeInclusive| scan(self.lookup.range((*run.start()).max(from)..=*run.end())); - match rev { - true => runs.rev().find_map(each), - false => runs.find_map(each), + // What is left to search, shrinking from the end the search starts at. + let (mut lo, mut hi) = (from, u64::MAX); + while lo <= hi { + let mut left = runs.overlapping(lo..=hi); + let run = match rev { + true => left.next_back(), + false => left.next(), + }?; + let (start, end) = ((*run.start()).max(lo), (*run.end()).min(hi)); + // The cache from the run on, toward the far end: one lookup either finds + // the run's groups or the next cached group past a run holding none. + let leap = match rev { + true => leap(self.lookup.range(lo..=end).rev(), start..=end, &mut f), + false => leap(self.lookup.range(start..=hi), start..=end, &mut f), + }; + match leap { + Leap::Found(found) => return Some(found), + Leap::Empty => return None, + Leap::Past(sequence) if rev => hi = sequence, + Leap::Past(sequence) => lo = sequence, + Leap::Exhausted if rev => hi = start.checked_sub(1)?, + Leap::Exhausted => lo = end.checked_add(1)?, + } } + None } } } @@ -1516,8 +1506,8 @@ impl TrackState { /// Resolve a one-shot fetch from the track side: the cached group, or an [`Error`] /// once it can never be served. A missing group is a failure ([`Error::NotFound`]), not an - /// end-of-stream. The handler side (a rejection, or no [`Dynamic`] at all) lives - /// in [`FetchState`]; [`Fetching`] polls both. + /// end-of-stream. The handler side (a refusal, or no [`Dynamic`] at all) lives + /// in the track's fills; [`Fetching`] polls both. fn poll_fetch_cached(&self, sequence: u64, frame_start: u64) -> Poll> { // A group aborted between the lookup and the consume (an abandoned fetch cut // short) is a miss, not a hit that fails on its first read. @@ -1839,7 +1829,7 @@ impl TrackState { /// subscribers ask for. fn arrival_grace(&self) -> Duration { let max_delay = snapshot_subscription(&self.subscriptions, self.max_age_bound()) - .map_or(Duration::ZERO, |subscription| subscription.max_delay); + .map_or(Duration::ZERO, |subscription| subscription.budget()); ledger::grace(max_delay) } @@ -2578,7 +2568,7 @@ impl TrackState { Ok(()) } - /// Insert a group fetched for a [`group::Request`], setting the track's [`Info`] + /// Insert a group a [`Fill`] answered, setting the track's [`Info`] /// if it isn't accepted yet. The group's timescale comes from that info, so a /// fetch can serve an as-yet-unaccepted track (e.g. a relay with no live /// subscription). The group lands in the cache so a waiting @@ -3532,84 +3522,34 @@ impl Producer { } } -/// Pop the next queued group fetch off the fetch queue and wrap it in a -/// [`group::Request`] bound to a fresh producer handle. Shared by every -/// [`Dynamic`] handle on the track. -fn poll_requested_group( - state: &kio::Producer, - fetch: &kio::Shared, - waiter: &kio::Waiter, -) -> Poll> { - // Prefer serving a queued fetch, even if the track has since aborted. - if let Poll::Ready(mut guard) = fetch.poll(waiter, |fetch| { - if fetch.has_queued() { - Poll::Ready(()) - } else { - Poll::Pending - } - }) { - let sequence = guard.pop().expect("predicate guaranteed a request"); - // The popped attempt stays pending, so a fetch in the window between hand-off - // and accept joins it instead of queueing a duplicate. - // `group::Request::{accept, reject, drop}` removes the entry, as does the last - // `Fetching` leaving. - let pending = guard.get(&sequence).expect("popped key must be pending"); - let priority = pending.priority; - let max_delay = pending.max_delay; - let frame_start = pending.frame_start; - let result = pending.result.clone(); - drop(guard); - return Poll::Ready(Ok(group::Request { - state: state.clone(), - fetch: fetch.clone(), - sequence, - priority, - max_delay, - frame_start, - result, - done: false, - })); - } - - // No fetch queued: surface a track abort so the handler loop can exit. - match state.poll_ref(waiter, |state| match &state.abort { - Some(err) => Poll::Ready(err.clone()), - None => Poll::Pending, - }) { - Poll::Ready(Ok(err)) => Poll::Ready(Err(err)), - Poll::Ready(Err(closed)) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), - Poll::Pending => Poll::Pending, - } -} - -/// Serves on-demand fetches of uncached (old) groups for a track, the group-level -/// analogue of [`broadcast::Dynamic`]. +/// Serves the groups a track's readers miss, the group-level analogue of +/// [`broadcast::Dynamic`]. /// /// Most tracks never serve old content, so this capability lives on a dedicated /// handle rather than [`Producer`]: a relay creates one (via /// [`Producer::dynamic`] or [`Request::dynamic`]) to pull past groups -/// from upstream. While at least one is alive the track will block a cache-miss -/// [`Consumer::fetch_group`] waiting to be served; with none, an accepted track's -/// miss fails fast with [`Error::NotFound`]. +/// from upstream. While at least one is alive a reader's miss queues a [`Fill`] for it +/// to serve; with none, the miss is named dropped, and a cache-miss +/// [`Consumer::fetch_group`] on an accepted track fails fast with [`Error::NotFound`]. pub struct Dynamic { name: Arc, // Kept to insert served groups into the cache and observe track abort. state: kio::Producer, - // The fetch queue this handle drains; its `dynamic` count gates `fetch_group`. - fetch: kio::Shared, - // Shared with the track's producers: a handler still serving fetches keeps the + // The fills this handle drains; its handler count gates queueing. + fills: kio::Shared, + // Shared with the track's producers: a handler still serving fills keeps the // track alive, like a producer clone does. alive: Arc, } impl Dynamic { fn new(name: Arc, state: kio::Producer, alive: Arc) -> Self { - let fetch = state.read().fetch.clone(); - fetch.lock().add_handler(); + let fills = state.read().fills.clone(); + fills.lock().add_handler(); Self { name, state, - fetch, + fills, alive, } } @@ -3619,19 +3559,34 @@ impl Dynamic { &self.name } - /// Block until a consumer fetches a group that isn't cached, returning a - /// [`group::Request`] to serve via [`group::Request::accept`]. + /// Block until readers miss a run of groups, returning a [`Fill`] to serve via + /// [`Fill::accept`] and [`Fill::drop_groups`]. /// - /// A relay issues a wire FETCH first; an origin already has the group cached, so - /// the fetch resolves without ever reaching here. Errors once the track is aborted. + /// A relay fetches the run upstream; an origin already has its groups cached, so + /// nothing reaches here. Errors once the track is aborted. #[cfg(feature = "async")] - pub async fn requested_group(&self) -> Result { - kio::wait(|waiter| self.poll_requested_group(waiter)).await + pub async fn requested_fill(&self) -> Result { + kio::wait(|waiter| self.poll_requested_fill(waiter)).await + } + + /// Poll for a fill to serve. + pub fn poll_requested_fill(&self, waiter: &kio::Waiter) -> Poll> { + fill::poll_requested(&self.state, &self.fills, waiter) + } + + /// The next fill, which a test expects to name a single group. + #[cfg(test)] + pub(crate) fn poll_requested_fill_group(&self, waiter: &kio::Waiter) -> Poll> { + self.poll_requested_fill(waiter).map_ok(|fill| { + assert_eq!(fill.end() - fill.start().group, 1, "a one-group fill"); + fill.group(fill.start().group) + }) } - /// Poll for a group request to serve. - pub fn poll_requested_group(&self, waiter: &kio::Waiter) -> Poll> { - poll_requested_group(&self.state, &self.fetch, waiter) + /// [`Self::poll_requested_fill_group`], awaited. + #[cfg(all(test, feature = "async"))] + pub(crate) async fn requested_fill_group(&self) -> Result { + kio::wait(|waiter| self.poll_requested_fill_group(waiter)).await } /// Watch subscriber demand without keeping the track alive. @@ -3643,11 +3598,11 @@ impl Dynamic { impl Clone for Dynamic { fn clone(&self) -> Self { // Count each live handle (mirrors `broadcast::Dynamic`). - self.fetch.lock().add_handler(); + self.fills.lock().add_handler(); Self { name: self.name.clone(), state: self.state.clone(), - fetch: self.fetch.clone(), + fills: self.fills.clone(), alive: self.alive.clone(), } } @@ -3656,14 +3611,10 @@ impl Clone for Dynamic { impl Drop for Dynamic { fn drop(&mut self) { // Unlike `broadcast::Dynamic`, dropping the last handle doesn't abort the track: - // a live `Producer` may still be serving the subscription. It just stops fetch - // serving. Queued attempts no handler will ever pop are dropped, closing their - // result channels so every joined `Fetching` resolves NotFound; an attempt - // already handed to a handler stays, resolved by its `group::Request` instead. - let mut fetch = self.fetch.lock(); - if fetch.remove_handler() { - fetch.drain_queued(); - } + // a live `Producer` may still be serving the subscription. It just stops serving + // fills: a queued one no handler will ever take closes, so its readers stop + // waiting on it, while one already taken stays with its `Fill`. + self.fills.lock().remove_handler(); } } @@ -3832,7 +3783,9 @@ fn clamp_max_delay(mut max_delay: Duration, bound: Option) -> Duration /// Clamp the aggregate's max delay budget to the publisher's window; see [`clamp_max_delay`]. fn clamp_combined(combined: Option, bound: Option) -> Option { let mut combined = combined?; - combined.max_delay = clamp_max_delay(combined.max_delay, bound); + if bound.is_some() { + combined.max_delay = Some(clamp_max_delay(combined.budget(), bound)); + } Some(combined) } @@ -4226,6 +4179,47 @@ impl ArrivalRead<'_> { res => res, } } + + /// The groups of `groups` [`Self::poll_group`] gives up on, judged by run, so a span a + /// peer made wide costs what the cache and ledger hold rather than its width. + // Legacy wires lack FETCH, so a held fill settles what the feed gave up in bulk. + pub(crate) fn given_up(&self, groups: ops::Range, now: crate::time::Instant) -> RangeSet { + let state = &*self.state; + let mut gone = RangeSet::new(); + let mut give_up = |groups: ops::Range| { + if !groups.is_empty() { + gone.insert(groups); + } + }; + let closed = self.state.is_closed(); + let start = state.start_sequence.unwrap_or(0); + let fin = state.final_sequence().unwrap_or(u64::MAX); + for hole in miss::holes(state, groups) { + if closed { + give_up(hole); + continue; + } + give_up(hole.start..hole.end.min(start)); + give_up(hole.start.max(fin)..hole.end); + for (dropped, _) in state.drops.overlapping(&hole) { + give_up(dropped.start.max(hole.start)..dropped.end.min(hole.end)); + } + // Every group of a gap folds at once: its grace runs from the run above it. + let folded = |gap: u64| state.arrivals.folds_at(gap, self.grace).is_some_and(|at| at <= now); + let mut from = hole.start; + for arrived in state.arrivals.accounted(hole.clone()) { + if from < arrived.start && folded(from) { + give_up(from..arrived.start); + } + from = arrived.end; + give_up(arrived); + } + if from < hole.end && folded(from) { + give_up(from..hole.end); + } + } + gone + } } /// A lazy handle to a single track within a broadcast. @@ -4647,8 +4641,8 @@ impl Consumer { /// (past the final sequence, or no [`Dynamic`] on the track), [`Error::NotFetchable`] /// instead when this track sent the sequence as a datagram still buffered, the handler's /// rejection (a relay's upstream miss is [`StreamError::NotFound`](StreamError::NotFound)), - /// or the track's abort error if it's already closed. Concurrent fetches for the same sequence coalesce onto one - /// handler request. + /// or the track's abort error if it's already closed. A miss queues a one-group [`Fill`], + /// which concurrent fetches of the same sequence join. pub fn fetch_group(&self, sequence: u64, options: impl Into>) -> kio::Pending { let options = options.into().unwrap_or_default(); @@ -4660,10 +4654,10 @@ impl Consumer { // Nothing more feeds a whole group, so no route needs to stay up for it: the copy a // front parked serves it without reviving the logical track for one read. if let Some((state, group)) = self.parked_whole(sequence, options.frame_start) { - let fetch = state.read().fetch.clone(); + let fills = state.read().fills.clone(); return kio::Pending::new(Fetching { state, - fetch, + fills, sequence, frame_start: options.frame_start, priority: options.priority, @@ -4683,7 +4677,7 @@ impl Consumer { if let Some(resume) = resume { return kio::Pending::new(Fetching { state: state.clone(), - fetch: state.read().fetch.clone(), + fills: state.read().fills.clone(), sequence, frame_start: options.frame_start, priority: options.priority, @@ -4698,10 +4692,10 @@ impl Consumer { // Queue a request only when the group isn't already resolvable from the track // (cached, aborted, or past-final all resolve through `Fetching::poll` without // a queue entry). - let (fetch, cached) = { + let (fills, cached) = { let state = state.read(); ( - state.fetch.clone(), + state.fills.clone(), state.poll_fetch_cached(sequence, options.frame_start), ) }; @@ -4714,12 +4708,12 @@ impl Consumer { }; if unresolved { - result = queue(&fetch, sequence, &options); + result = queue(&fills, sequence, &options); } kio::Pending::new(Fetching { state, - fetch, + fills, sequence, frame_start: options.frame_start, priority: options.priority, @@ -4959,158 +4953,25 @@ impl kio::Task for Querying { } } -impl group::Request { - /// Watch the callers waiting for this fetch without keeping the attempt alive. - /// - /// The last caller to leave withdraws the attempt, so a later fetch of the group - /// queues a fresh request rather than joining this one: once unused, demand never - /// returns, so drop the request. - pub fn demand(&self) -> group::Demand { - group::Demand::fetch(self.sequence, self.result.weak()) - } - - /// The group sequence the consumer wants. - pub fn sequence(&self) -> u64 { - self.sequence - } - - /// The delivery priority the consumer requested for this group. - pub fn priority(&self) -> i8 { - self.priority - } - - /// The widest age budget among callers sharing this upstream fetch, for a handler that forwards it. - /// Each caller still enforces its own, so a handler serving locally can ignore it. - pub fn max_delay(&self) -> Option { - self.max_delay - } - - /// The first frame of the group the consumer wants; 0 is the whole group. - /// - /// The group [`accept`](Self::accept) returns already starts here, so the frames a - /// handler writes carry the indices they have in the group rather than restarting at - /// 0. A handler serving from elsewhere moves it with - /// [`start_at`](group::Producer::start_at) before the first frame. - /// - /// There is no end: the handler fetches through the end of the group so the result - /// is cacheable for anyone (see [`group::Fetch::frame_start`]). - pub fn frame_start(&self) -> u64 { - self.frame_start - } - - /// Insert the fetched group into the track cache, resolving the waiting - /// [`Consumer::fetch_group`], and return a [`group::Producer`] to fill. - /// - /// The group's timescale comes from the track's [`Info`]. `info` sets that - /// info if the track hasn't been accepted yet (a fetch with no live subscription), - /// and is ignored once accepted. Returns [`Error::Duplicate`] if the group is - /// already present, or the track's abort error if it closed while pending. - /// - /// Accepting after every caller left still caches the group, which a fetch that - /// queued a fresh request meanwhile resolves from; that request's own accept is then - /// [`Error::Duplicate`]. - pub fn accept(mut self, info: impl Into>) -> Result { - self.done = true; - // Cache the group before removing the attempt: the joined fetches resolve - // through the cache, and removal closes their result channel (which alone - // would read as NotFound). - let res = TrackState::modify(&self.state) - .and_then(|mut state| state.insert_group_request(self.sequence, self.frame_start, info.into())); - self.remove(); - // A joined fetch the cached group can't cover (a wider start that joined after - // the range went on the wire) fails rather than waits. Closing the channel says - // the same, but a handler tracking the joined demand keeps it open. - if let Ok(mut outcome) = self.result.write() { - outcome.accepted = true; - } - res - } - - /// Declare the track's exclusive final sequence, as the publisher answering this - /// fetch reported it. A no-op once the track declared an end, or holds a later group. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn finish_track_at(&self, final_sequence: u64) { - if let Ok(mut state) = TrackState::modify(&self.state) - && state.end.is_none() - { - let _ = state.set_end(Position::group(final_sequence)); - } - } - - /// Reject the fetch, resolving every joined [`Consumer::fetch_group`] with `err`. - pub fn reject(mut self, err: Error) { - self.done = true; - // Remove before writing, so a fetch arriving now starts a fresh attempt - // instead of joining a rejected one. - self.remove(); - if let Ok(mut outcome) = self.result.write() { - outcome.rejected = Some(err); - outcome.asked = self.max_delay; - } - } - - /// Remove this attempt from the fetch state, unless a newer attempt for the same - /// sequence has already replaced it. - fn remove(&self) { - withdraw(&mut self.fetch.lock(), self.sequence, |pending| { - pending.result.same_channel(&self.result) - }); - } -} - -/// Join the attempt for `sequence`, or queue a new one; `None` when no handler exists. -fn queue(fetch: &kio::Shared, sequence: u64, options: &group::Fetch) -> Option> { - let mut fetch = fetch.lock(); - if let Some(pending) = fetch.join(&sequence) { - // Join the in-flight attempt for this sequence (queued or already being - // served): share its result channel, raising its priority if ours is higher, - // widening its range if ours starts earlier, and its budget if ours is looser. - // - // Widening only reaches the handler while the attempt is still queued; - // once popped, the `group::Request` holds an immutable copy that is already on - // the wire. What protects the late caller either way: the coverage check - // refuses a group starting above what it asked for, so it fails cleanly and its - // retry queues a fresh attempt, and `Fetching::poll` re-queues a looser budget - // that a tighter one got refused as too old. - pending.priority = pending.priority.max(options.priority); - pending.max_delay = match (pending.max_delay, options.max_delay) { - (Some(current), Some(joined)) => Some(current.max(joined)), - (None, _) | (_, None) => None, - }; - pending.frame_start = pending.frame_start.min(options.frame_start); - return Some(Joined::new(&pending.result)); - } - // Queue a new attempt. The handler gate is atomic with a handler dropping (no fetch - // stranded on a queue nobody drains); with no handler, `Fetching::poll` fails fast. - let producer = kio::Producer::::default(); - let joined = Joined::new(&producer); - let attempt = PendingFetch { +/// Join the fill covering `sequence`, or queue a one-group fill; `None` when no handler +/// exists to take one, or for the last sequence, which a fill's exclusive end can't name. +fn queue(fills: &kio::Shared, sequence: u64, options: &group::Fetch) -> Option> { + let end = sequence.checked_add(1)?; + let ask = fill::Ask { + start: Position { + group: sequence, + frame: options.frame_start, + }, + end, priority: options.priority, max_delay: options.max_delay, - frame_start: options.frame_start, - result: producer, + // One group either way; newest first is the default order. + order: Order::default(), }; - fetch.insert(sequence, attempt).ok().map(|()| joined) -} - -/// Remove the attempt for `sequence` if it is still `ours`. Reads first: the attempt is -/// often already gone, and a write would wake every handler parked on the queue. -fn withdraw(fetch: &mut kio::Mut<'_, FetchState>, sequence: u64, ours: impl Fn(&PendingFetch) -> bool + Copy) { - if fetch.get(&sequence).is_some_and(ours) { - fetch.remove_if(&sequence, ours); - } -} - -impl Drop for group::Request { - fn drop(&mut self) { - if self.done { - return; - } - self.remove(); - if let Ok(mut outcome) = self.result.write() { - outcome.rejected = Some(Error::Dropped); - } - } + // The handler gate is atomic with a handler dropping (no fill stranded on a queue + // nobody drains); with no handler, `Fetching::poll` fails fast. + let (joins, _) = fills.lock().join(fills, &ask); + joins.into_iter().next().map(Box::new) } /// The pollable state of a [`Consumer::fetch_group`]. @@ -5120,7 +4981,7 @@ impl Drop for group::Request { /// or produced after a wire FETCH), or [`Error::NotFound`] if it can never exist. pub struct Fetching { state: kio::Consumer, - fetch: kio::Shared, + fills: kio::Shared, sequence: u64, // This caller's own start, so a cached group that begins above it is a miss // rather than a short answer. @@ -5129,9 +4990,9 @@ pub struct Fetching { priority: i8, // This caller's own age cap. None means only the publisher's max age. max_delay: Option, - // The attempt this fetch joined; `None` on a cache hit, or when no handler - // existed to queue on. Boxed: a miss already allocates, and the handle stays small. - result: Option>, + // The fill this fetch joined; `None` on a cache hit, or when no handler existed to + // queue on. Boxed: a miss already allocates, and the handle stays small. + result: Option>, // The group already cached when the fetch was made, held so it stays wanted. // Boxed: a group consumer dwarfs the rest of the handle. hit: Option>, @@ -5142,42 +5003,6 @@ pub struct Fetching { resume: Option>, } -/// A [`Fetching`]'s place in a queued or in-flight attempt. -struct Joined { - // Counts this caller in the attempt's demand and carries its outcome. - outcome: kio::Consumer, - // Names the attempt in the fetch state, so the last caller out withdraws only its own. - attempt: kio::ProducerWeak, -} - -impl Joined { - fn new(result: &kio::Producer) -> Box { - Box::new(Self { - outcome: result.consume(), - attempt: result.weak(), - }) - } -} - -impl Drop for Fetching { - fn drop(&mut self) { - let Some(joined) = self.result.take() else { - return; - }; - let Joined { outcome, attempt } = *joined; - // Joins take the fetch lock, so leaving under it is atomic with one: the joiner - // either keeps the attempt wanted, or finds it withdrawn and queues a fresh one - // rather than joining one its handler is about to drop. - let mut fetch = self.fetch.lock(); - drop(outcome); - if !attempt.is_used() { - withdraw(&mut fetch, self.sequence, |pending| { - pending.result.weak().same_channel(&attempt) - }); - } - } -} - impl kio::Task for Fetching { type Output = Result; @@ -5198,9 +5023,9 @@ impl kio::Task for Fetching { let group = ready!(kio::Task::poll(&mut **resume, waiter))?; return Poll::Ready(Ok(group.with_meter(self.stats.meter()))); } - let (state, fetch, sequence, frame_start, result) = ( + let (state, fills, sequence, frame_start, result) = ( &self.state, - &self.fetch, + &self.fills, self.sequence, self.frame_start, self.result.as_ref(), @@ -5246,10 +5071,15 @@ impl kio::Task for Fetching { // Handler side. let Some(result) = result else { + // A fill's end is exclusive, so no handler can be asked for the last sequence: + // only a local write, already looked for above, can hold it. + if sequence == u64::MAX { + return Poll::Ready(Err(Error::NotFound)); + } // Never queued: no handler existed when the fetch was made. Fail fast while // that's still true; a handler that appeared since may yet fill the cache. // A sequence this track sent as a datagram says so, rather than a plain miss. - return match fetch.poll(waiter, |fetch| match fetch.has_handlers() { + return match fills.poll(waiter, |fills| match fills.has_handlers() { false => Poll::Ready(()), true => Poll::Pending, }) { @@ -5261,13 +5091,15 @@ impl kio::Task for Fetching { }; }; - // An answered attempt fails joined fetches the cache cannot cover. The channel + // An answered fill fails a joined fetch the cache cannot cover. The channel // closing without an answer means its handlers went away. - let (err, asked) = match result.outcome.poll(waiter, |outcome| match &outcome.rejected { - Some(err) => Poll::Ready((err.clone(), outcome.asked)), - None if outcome.accepted => Poll::Ready((Error::NotFound, None)), - None => Poll::Pending, - }) { + let (err, asked) = match result + .outcome() + .poll(waiter, |outcome| match outcome.refused(sequence) { + Some(err) => Poll::Ready((err.clone(), outcome.asked())), + None if outcome.is_accepted(sequence) => Poll::Ready((Error::NotFound, None)), + None => Poll::Pending, + }) { Poll::Ready(Ok(res)) => res, Poll::Ready(Err(_closed)) => (Error::NotFound, None), Poll::Pending => return Poll::Pending, @@ -5279,7 +5111,7 @@ impl kio::Task for Fetching { } // Joined after a tighter budget went on the wire, which judged the group too old // for that budget, not ours: ask again with ours. Terminates, since the new - // attempt asks at least our budget. + // fill asks at least our budget. let looser = match (asked, self.max_delay) { (Some(asked), Some(ours)) => ours > asked, (Some(_), None) => true, @@ -5291,7 +5123,7 @@ impl kio::Task for Fetching { max_delay: self.max_delay, frame_start: self.frame_start, }; - self.result = queue(&self.fetch, self.sequence, &options); + self.result = queue(&self.fills, self.sequence, &options); return self.poll(waiter); } Poll::Ready(Err(err)) @@ -5462,7 +5294,7 @@ impl group::Expiry for GroupExpiry { let max_delay = max_delay.unwrap_or_else(|| { let mut max_delay = Duration::default(); let _ = self.subscription.poll(waiter, |subscription| { - max_delay = subscription.max_delay; + max_delay = subscription.budget(); Poll::<()>::Pending }); max_delay @@ -5543,6 +5375,42 @@ impl Published { } } +/// What one run's lookup found, for [`TrackState::find_servable`] leapfrogging the cache. +enum Leap { + /// A group in the run that the search answered. + Found(R), + /// The run's groups, none answering. + Exhausted, + /// Nothing in the run: the nearest cached group past it. + Past(u64), + /// Nothing cached from the run on. + Empty, +} + +/// Search `cached`, ordered from the run toward the far end, for a group in `run` that `f` +/// answers. +fn leap<'a, R>( + mut cached: impl Iterator, + run: ops::RangeInclusive, + f: &mut impl FnMut(&'a Slot) -> Option, +) -> Leap { + let Some((&first, slot)) = cached.next() else { + return Leap::Empty; + }; + if !run.contains(&first) { + return Leap::Past(first); + } + let mut within = std::iter::once(slot).chain( + cached + .take_while(|(sequence, _)| run.contains(*sequence)) + .map(|(_, slot)| slot), + ); + match within.find_map(f) { + Some(found) => Leap::Found(found), + None => Leap::Exhausted, + } +} + /// The groups a reader can be served, the only ones its drift is measured against: one it /// never reads, past its cap or in a gap between its ranges, never stands in for one it does. #[derive(Clone, PartialEq)] @@ -5683,14 +5551,27 @@ struct Cursor { seek_pending: BTreeMap, /// The drops a wire publisher forwards, once it first polls for one. drops: Option>, + /// What this cursor misses and asks fills for, once it first reads. + misses: Option>, } struct Peeked { group: group::Consumer, - index: Option, + taken: Taken, ordered: bool, } +/// Where a recv-order candidate came from, which taking it advances past. +#[derive(Clone, Copy)] +enum Taken { + /// The arrival walk, which moves to this index. + Walk(usize), + /// What a widening owes. + Owed, + /// What a fill answered. + Filled, +} + /// The sequences a [`DropCursor`] reports from: a cursor's span, narrowed to its window. struct Within<'a> { span: ops::Range, @@ -5728,6 +5609,15 @@ impl DropCursor { } } + /// Record a run this cursor's fills gave up on as a miss: why it is gone is unknown, so + /// a reason already pending, such as the track's own drop, is kept. + fn give_up(&mut self, run: ops::Range) { + let gaps: Vec<_> = self.pending.gaps(&run).collect(); + for gap in gaps { + self.pending.insert(gap, StreamError::NotFound); + } + } + /// Record `slot`, cached at `sequence`, if it holds a cut. fn cut(&mut self, sequence: u64, slot: &Slot) { if slot.visible @@ -5834,7 +5724,7 @@ impl Cursor { let start = newest.map_or(0, |newest| { // Every datagram carries the track's scale: convert the cutoff into it once, // rounded up, rather than each timestamp into a `Duration` (a 128-bit division). - let cutoff = Duration::from(newest).saturating_sub(preferences.max_delay); + let cutoff = Duration::from(newest).saturating_sub(preferences.budget()); let scale = newest.scale().as_u64() as u128; let cutoff = (cutoff.as_nanos() * scale).div_ceil(1_000_000_000) as u64; let cutoff = Timestamp::new(cutoff, newest.scale()).expect("at most the newest"); @@ -5872,12 +5762,27 @@ impl Cursor { stale: stats::Content::default(), seek_pending: BTreeMap::new(), drops: None, + misses: None, } } #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] fn poll_drop(&mut self, waiter: &kio::Waiter) -> Poll<(ops::Range, StreamError)> { self.sync(); + if self.drops.is_none() { + let mut drops = Box::new(DropCursor::new(&self.state.read())); + if let Some(misses) = &self.misses { + for run in misses.given_up().iter() { + drops.give_up(run.clone()); + } + } + self.drops = Some(drops); + } + if self.filter.refused.is_none() + && let Poll::Ready(Ok(drift)) = self.poll_drift(&self.servable, waiter) + { + self.probe(self.floor(), &drift, waiter); + } let within = Within { span: self.floor()..self.end_sequence.unwrap_or(u64::MAX), window: &self.filter.window, @@ -6115,7 +6020,7 @@ impl Cursor { fn poll_drift(&self, servable: &Servable, waiter: &kio::Waiter) -> Poll> { let mut max_delay = Duration::default(); let _ = self.subscription.poll_ref(waiter, |subscription| { - max_delay = subscription.max_delay; + max_delay = subscription.budget(); Poll::<()>::Pending }); self.poll(waiter, |state| { @@ -6142,7 +6047,7 @@ impl Cursor { if delivered.above_gap().is_none() { return; } - let max_delay = self.subscription.read().max_delay; + let max_delay = self.subscription.read().budget(); let state = self.state.read(); let budget = clamp_max_delay(max_delay, state.max_age_bound()); let edge = state.drift_edge(&self.servable); @@ -6196,12 +6101,78 @@ impl Cursor { })) } + /// Ask fills for what this cursor misses from `from`, and take in what they answered, + /// parking `waiter` on the fills still out. Whatever it gives up goes to the drop cursor. + fn probe(&mut self, from: u64, drift: &Drift, waiter: &kio::Waiter) { + let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); + let asking = { + let subscription = self.subscription.read(); + miss::Asking { + window, + priority: subscription.priority, + max_delay: subscription.max_delay, + order: subscription.order, + } + }; + let fills = self.state.read().fills.clone(); + let misses = self.misses.get_or_insert_with(Box::default); + let mut given_up = Vec::new(); + if misses.lowest().is_some() { + misses.retain(from, &below(window.missable(from), cap)); + } + let asks = { + let state = self.state.read(); + misses.judge( + &state, + (from, revision, cap), + || below(window.missable(from), cap), + drift, + &mut given_up, + waiter, + ) + }; + misses.ask(&self.state, &fills, &asks, &asking, &mut given_up); + loop { + let retry = misses.resolve(&self.state, asking.max_delay, &mut given_up, waiter); + if retry.is_empty() { + break; + } + misses.ask(&self.state, &fills, &retry, &asking, &mut given_up); + } + misses.expire(&self.state.read(), drift, &mut given_up, waiter); + if let Some(drops) = &mut self.drops { + for run in given_up { + drops.give_up(run); + } + } + } + + /// The next group a fill answered for this cursor in its order, cached and not handed + /// out yet. + fn next_filled(&mut self) -> Option { + let misses = self.misses.as_mut()?; + let order = self.subscription.read().order; + let mut given_up = Vec::new(); + let group = misses.next_filled(&self.state.read(), order, &mut given_up); + if let Some(drops) = &mut self.drops { + for run in given_up { + drops.give_up(run); + } + } + group + } + + /// Whether a fill still owes this cursor a group, so the end of the feed is not its end. + fn awaits_fill(&self) -> bool { + self.misses.as_ref().is_some_and(|misses| misses.is_waiting()) + } + fn poll_recv_group(&mut self, take: bool, waiter: &kio::Waiter) -> Poll>> { if let Some(peeked) = self.peeked.take().filter(|peeked| take && !peeked.ordered) { // The reserved group is still the one handed out, but bounded by the ranges in // force now, which a `Control` may have replaced since the peek. self.sync(); - self.advance_arrival(peeked.group.sequence, peeked.index); + self.advance_arrival(peeked.group.sequence, peeked.taken); peeked.group.cache_refresh(); return Poll::Ready(Ok(Some(self.deliver(peeked.group)))); } @@ -6213,6 +6184,7 @@ impl Cursor { // One scan for the whole poll, so walking a backlog off stays linear in its size. let drift = ready!(self.poll_drift(&self.servable, waiter))?; + self.probe(self.floor(), &drift, waiter); self.poll_recv_with(take, drift, waiter) } @@ -6228,15 +6200,20 @@ impl Cursor { // Re-offer what a widening newly covers ahead of the arrival walk, lowest first: // it is the oldest thing still owed. A group the walk has yet to reach comes // again from it, and the subscriber's delivered set drops the repeat. - let (consumer, next_index) = match self.next_owed() { - Some(producer) => { + let owed = match self.next_owed() { + Some(producer) => Some((producer, Taken::Owed)), + // A fill answered what the walk never reaches: backfill is not in it. + None => self.next_filled().map(|producer| (producer, Taken::Filled)), + }; + let (consumer, taken) = match owed { + Some((producer, taken)) => { let consumer = producer.consume(); // A re-offer is a delivery: stamp it like a fresh hand-out. Peeking is // not an access. if take { consumer.cache_refresh(); } - (consumer, None) + (consumer, taken) } None => { let floor = self.floor(); @@ -6248,6 +6225,10 @@ impl Cursor { continue; } let Some((producer, found_index)) = ready!(polled)? else { + // The feed ended, but a fill still owes a group. Its answer wakes us. + if self.awaits_fill() { + return Poll::Pending; + } return Poll::Ready(Ok(None)); }; let consumer = producer.consume(); @@ -6279,7 +6260,7 @@ impl Cursor { self.index = found_index + 1; continue; } - (consumer, Some(found_index + 1)) + (consumer, Taken::Walk(found_index + 1)) } }; @@ -6289,7 +6270,7 @@ impl Cursor { // A peek discards what cannot be delivered, but keeps its candidate at the // same cursor position until the reader chooses this copy. if take || stale { - self.advance_arrival(consumer.sequence, next_index); + self.advance_arrival(consumer.sequence, taken); } if stale { self.stale.add(consumer.content()); @@ -6305,7 +6286,7 @@ impl Cursor { } else { self.peeked = Some(Box::new(Peeked { group: consumer.clone(), - index: next_index, + taken, ordered: false, })); consumer @@ -6315,10 +6296,15 @@ impl Cursor { /// Move the arrival cursor past a group it read: from the walk, to `index`; from what /// a widening owes, out of the owed spans. - fn advance_arrival(&mut self, sequence: u64, index: Option) { - match index { - Some(index) => self.index = index, - None => self.filter.owed.remove(sequence..=sequence), + fn advance_arrival(&mut self, sequence: u64, taken: Taken) { + match taken { + Taken::Walk(index) => self.index = index, + Taken::Owed => self.filter.owed.remove(sequence..=sequence), + Taken::Filled => { + if let Some(misses) = &mut self.misses { + misses.take_filled(sequence); + } + } } } @@ -6393,6 +6379,7 @@ impl Cursor { let floor = next.max(self.floor()); // One scan for the whole poll, so walking a backlog off stays linear in its size. let drift = ready!(self.poll_drift(&self.servable, waiter))?; + self.probe(floor, &drift, waiter); self.poll_next_with(take, floor, drift, waiter) } @@ -6415,12 +6402,26 @@ impl Cursor { continue; } let Some(producer) = ready!(polled)? else { + // The feed ended, but a fill still owes a group. Its answer wakes us. + if self.awaits_fill() { + return Poll::Pending; + } // Deliberately no flush of `seek_pending` here: only a delivery commit // may count a conviction. This `None` can be an artifact of a floor // that will lower again. A conviction never committed is dropped // uncounted with the cursor. return Poll::Ready(Ok(None)); }; + // In order means a group a fill still owes below this one comes first. Its + // answer, or giving it up, wakes us. + if self + .misses + .as_ref() + .and_then(|misses| misses.lowest_wanted()) + .is_some_and(|wanted| wanted < producer.sequence) + { + return Poll::Pending; + } let group = producer.consume(); // The first group to arrive resolves a relative floor, which may land above it, and @@ -6460,7 +6461,7 @@ impl Cursor { } else { self.peeked = Some(Box::new(Peeked { group: group.clone(), - index: None, + taken: Taken::Owed, ordered: true, })); group @@ -6469,6 +6470,10 @@ impl Cursor { fn advance_sequence(&mut self, group: group::Consumer) -> group::Consumer { self.next_sequence = group.sequence.checked_add(1); + // The reader holds the group itself now, as on the arrival path. + if let Some(misses) = &mut self.misses { + misses.take_filled(group.sequence); + } // The delivery commits everything the seek stepped over to reach this group. self.commit_seek_stale(self.next_sequence.unwrap_or(u64::MAX)); group.cache_refresh(); @@ -6927,7 +6932,8 @@ impl Subscriber { /// Poll for where the source's feed starts, raised to this cursor's floor, once /// resolved: the first group a publisher tells its peer it serves. A feed starting - /// below the floor serves the floor's group too. `None` when the source declares none. + /// below the floor serves the floor's group too, and a fill this cursor waits on starts it + /// lower still. `None` when the source declares none and no fill is out. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn poll_start(&mut self, waiter: &kio::Waiter) -> Poll> { let cursor = match &mut self.inner { @@ -6936,7 +6942,12 @@ impl Subscriber { }; let start = ready!(Consumer::poll_state_start(&cursor.state, waiter)); cursor.sync(); - Poll::Ready(start.map(|start| start.max(cursor.floor()))) + let start = start.map(|start| start.max(cursor.floor())); + let filling = cursor.misses.as_ref().and_then(|misses| misses.lowest()); + Poll::Ready(match (start, filling) { + (Some(start), Some(filling)) => Some(start.min(filling)), + (start, filling) => start.or(filling), + }) } /// The lowest group this subscriber's ranges name, as resolved here when it last read, @@ -9025,6 +9036,19 @@ mod test { ); } + /// A fill's end is exclusive, so the last sequence can't be asked of a handler: its fetch + /// fails at once rather than parking until every handler goes. + #[test] + #[cfg(feature = "async")] + fn the_last_sequence_misses_with_a_handler() { + let producer = track_producer("test", None); + let dynamic = producer.dynamic(); + let consumer = producer.consume(); + let fetch = consumer.fetch_group(u64::MAX, None).now_or_never(); + assert!(matches!(fetch, Some(Err(Error::NotFound)))); + assert!(dynamic.requested_fill().now_or_never().is_none(), "nothing to fill"); + } + /// The subscription's floor and cap bound datagrams as they bound groups. The datagrams /// in range, sent between the dropped ones, show the reader is live, so the range is /// what dropped the others. @@ -10012,19 +10036,22 @@ mod test { // waited for longer than the publisher keeps it. The subscriber's own preference // is stored verbatim, so what it asked for stays readable. let mut subscriber = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); - assert_eq!(subscriber.subscription().max_delay, Duration::from_secs(10)); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(subscriber.subscription().max_delay, Some(Duration::from_secs(10))); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); // A budget within the cache is left alone, and ZERO (skip immediately) stays ZERO. subscriber .update(Subscription::default().with_max_delay(Duration::from_millis(500))) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_millis(500)); + assert_eq!( + producer.subscription().unwrap().max_delay, + Some(Duration::from_millis(500)) + ); subscriber .update(Subscription::default().with_max_delay(Duration::ZERO)) .unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::ZERO); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::ZERO)); } /// Mint a track under an origin whose retention ceiling is `cap`, so the @@ -10101,13 +10128,13 @@ mod test { // The clamp lives in the aggregation, so it applies no matter which entry point // wrote the raw preference. Previously only `Subscriber::update` clamped. let mut subscriber = producer.subscribe(over.clone()); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); subscriber.control().update(over.clone()).unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); subscriber.update(over).unwrap(); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); } #[test] @@ -10119,7 +10146,7 @@ mod test { let _a = producer.subscribe(Subscription::default().with_max_delay(Duration::from_millis(500))); let _b = producer.subscribe(Subscription::default().with_max_delay(Duration::from_secs(10))); - assert_eq!(producer.subscription().unwrap().max_delay, Duration::from_secs(2)); + assert_eq!(producer.subscription().unwrap().max_delay, Some(Duration::from_secs(2))); } #[test] @@ -11709,7 +11736,7 @@ mod test { let dynamic = request.dynamic(); let consumer = request.consume(); let mut pending = consumer.fetch_group(2, None); - let Poll::Ready(Ok(req)) = dynamic.poll_requested_group(&waiter) else { + let Poll::Ready(Ok(req)) = dynamic.poll_requested_fill_group(&waiter) else { panic!("fetch request should be ready"); }; let mut fetched = req.accept(None).unwrap(); @@ -11750,7 +11777,7 @@ mod test { let pending = consumer.fetch_group(100, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("fetch request is ready") .unwrap(); @@ -12429,7 +12456,7 @@ mod test { .unwrap(); let _fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"snapshot")) .unwrap(); @@ -12808,7 +12835,7 @@ mod test { assert_eq!(arrival.assert_group().sequence, 1); assert!(arrival.recv_group().now_or_never().is_none()); // The subscription's max delay is the grace. - elapse(&producer, replay().max_delay); + elapse(&producer, replay().budget()); assert!(arrival.recv_group().now_or_never().unwrap().unwrap().is_none()); // So does the last producer going. @@ -12829,7 +12856,7 @@ mod test { #[cfg(feature = "async")] async fn a_parked_reader_wakes_when_the_tail_grace_passes() { // The subscription's max delay is the grace. - let grace = replay().max_delay; + let grace = replay().budget(); // Hole 0 sits below arrival 1; holes 2..4 sit above the last arrival. for (end, arrived_late) in [(2, false), (4, true)] { let mut producer = track_producer("test", None); @@ -12855,7 +12882,16 @@ mod test { arrival.poll_recv_group(&waiter).is_pending(), "end {end}: held at the hole" ); - elapse(&producer, grace - Duration::from_millis(1)); + if arrived_late { + // Hole 0 folds a grace after the arrival: the reader wakes to judge it a miss, + // which nothing fills, and stays held for the holes above. + elapse(&producer, grace / 2); + assert!(woken.swap(false, Ordering::SeqCst), "end {end}: the fold went unjudged"); + assert!(arrival.poll_recv_group(&waiter).is_pending()); + elapse(&producer, grace / 2 - Duration::from_millis(1)); + } else { + elapse(&producer, grace - Duration::from_millis(1)); + } assert!(!woken.load(Ordering::SeqCst), "end {end}: woken before the grace"); assert!(arrival.poll_recv_group(&waiter).is_pending()); elapse(&producer, Duration::from_millis(1)); @@ -12874,7 +12910,7 @@ mod test { fn a_delivering_feed_holds_an_end_declared_ahead() { let mut producer = track_producer("test", None); producer.received(); - let grace = replay().max_delay; + let grace = replay().budget(); let mut arrival = producer.subscribe(replay()); producer.start_at(0).unwrap(); producer.finish_at(Position::group(5)).unwrap(); @@ -12900,7 +12936,7 @@ mod test { fn a_late_start_restarts_the_tail_grace() { let mut producer = track_producer("test", None); producer.received(); - let grace = replay().max_delay; + let grace = replay().budget(); let mut arrival = producer.subscribe(replay()); producer.finish_at(Position::group(2)).unwrap(); elapse(&producer, grace - Duration::from_millis(1)); @@ -12990,7 +13026,7 @@ mod test { assert_eq!(arrival.assert_group().sequence, 1); producer.finish_at(Position::group(2)).unwrap(); assert!(arrival.recv_group().now_or_never().is_none(), "group 0 is owed"); - elapse(&producer, replay().max_delay); + elapse(&producer, replay().budget()); assert!(arrival.recv_group().now_or_never().unwrap().unwrap().is_none()); } @@ -13010,7 +13046,7 @@ mod test { assert_eq!(arrival.assert_group().sequence, 1); let _pending = consumer.fetch_group(0, None); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); req.accept(None).unwrap().finish().unwrap(); assert!( arrival.recv_group().now_or_never().is_none(), @@ -14408,7 +14444,7 @@ mod test { assert_eq!(&g.read_frame().await.unwrap().unwrap().payload[..], b"hello"); // Nothing was queued for the dynamic handler to serve. - assert!(dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending()); + assert!(dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending()); } #[moq_clock::test] @@ -14426,7 +14462,7 @@ mod test { assert!(kio::Task::poll(&mut *pending, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14454,14 +14490,14 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); req.reject(Error::Cancel); assert!(matches!(pending.await, Err(Error::Cancel))); - let fetch = producer.state.read().fetch.clone(); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -14474,7 +14510,7 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14494,25 +14530,25 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); let second = consumer.fetch_group(5, None); drop(first); - assert!(req.demand().poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(req.poll_unused(&kio::Waiter::noop()).is_pending()); drop(second); - assert!(req.demand().poll_unused(&kio::Waiter::noop()).is_ready()); + assert!(req.poll_unused(&kio::Waiter::noop()).is_ready()); let retry = consumer.fetch_group(5, None); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); drop(req); - assert!(fresh.demand().poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(fresh.poll_unused(&kio::Waiter::noop()).is_pending()); fresh.accept(Info::timed()).unwrap().finish().unwrap(); assert_eq!(retry.await.unwrap().sequence, 5); } @@ -14527,8 +14563,8 @@ mod test { let consumer = producer.consume(); drop(consumer.fetch_group(5, None)); - assert!(dynamic.requested_group().now_or_never().is_none()); - let fetch = producer.state.read().fetch.clone(); + assert!(dynamic.requested_fill_group().now_or_never().is_none()); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -14543,7 +14579,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14554,7 +14590,7 @@ mod test { assert!(kio::Task::poll(&mut *retry, &kio::Waiter::noop()).is_pending()); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -14573,7 +14609,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14581,7 +14617,7 @@ mod test { let retry = consumer.fetch_group(5, None); let fresh = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -14594,7 +14630,7 @@ mod test { let mut fetched = retry.await.unwrap(); assert_eq!(&fetched.read_frame().await.unwrap().unwrap().payload[..], b"hi"); - assert!(!fresh.demand().is_used(), "the retry left the fresh request"); + assert!(!fresh.is_used(), "the retry left the fresh request"); assert!(matches!(fresh.accept(Info::timed()), Err(Error::Duplicate))); } @@ -14618,7 +14654,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14646,7 +14682,7 @@ mod test { let _tail = consumer.fetch_group(5, group::Fetch::default().with_frame_start(3)); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14654,7 +14690,7 @@ mod test { let mut whole = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the wider fetch misses and queues its own request") .unwrap(); @@ -14673,7 +14709,7 @@ mod test { let first = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14683,7 +14719,7 @@ mod test { let retry = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("the retry queues a fresh request") .unwrap(); @@ -14700,7 +14736,7 @@ mod test { let pending = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14709,7 +14745,7 @@ mod test { let retry = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -14754,7 +14790,7 @@ mod test { "must not answer from the tail" ); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); assert_eq!((request.sequence(), request.frame_start()), (0, 0)); // Serving it replaces the too-narrow entry rather than colliding with it. @@ -14788,7 +14824,7 @@ mod test { .unwrap(); let fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -14832,7 +14868,12 @@ mod test { live.start_at(1).unwrap(); live.write_frame(Timestamp::ZERO, b"delta".as_slice()).unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic + .requested_fill_group() + .await + .unwrap() + .accept(Info::timed()) + .unwrap(); for _ in 0..3 { head.write_frame(Timestamp::ZERO, b"x".as_slice()).unwrap(); } @@ -14858,7 +14899,7 @@ mod test { .unwrap(); let fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -14890,7 +14931,7 @@ mod test { ); let _fetch = consumer.fetch_group(0, None); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); let mut head = request.accept(Info::timed()).unwrap(); for payload in [&b"snapshot"[..], b"delta"] { head.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(payload)) @@ -14982,7 +15023,12 @@ mod test { .unwrap(); live.finish().unwrap(); let fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_group().await.unwrap().accept(Info::timed()).unwrap(); + let mut head = dynamic + .requested_fill_group() + .await + .unwrap() + .accept(Info::timed()) + .unwrap(); head.write_frame(Timestamp::ZERO, bytes::Bytes::from(vec![0u8; 10_000])) .unwrap(); head.finish().unwrap(); @@ -15033,7 +15079,7 @@ mod test { let mut wider = std::pin::pin!(_wider); assert!(futures::poll!(wider.as_mut()).is_pending()); - let request = dynamic.requested_group().await.unwrap(); + let request = dynamic.requested_fill_group().await.unwrap(); assert_eq!(request.frame_start(), 2, "widened while queued"); // Handed off now: the handler holds its own copy of the range, so a later wider @@ -15043,9 +15089,9 @@ mod test { assert!(futures::poll!(widest.as_mut()).is_pending()); assert_eq!(request.frame_start(), 2, "the in-flight range is already on the wire"); - // A handler may keep tracking the joined demand past the accept, as the lite + // A handler may keep tracking the group's readers past the accept, as the lite // subscriber does, which holds the result channel open. - let _joined = request.result.clone(); + let _readers = request.readers(); let mut group = request.accept(Info::timed()).unwrap(); // A handler numbers the frames from where it was asked to start, as `serve_fetch` // does; that offset is what makes the cached group too narrow for `widest`. @@ -15087,7 +15133,7 @@ mod test { assert!(kio::Task::poll(&mut *first, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -15095,7 +15141,7 @@ mod test { assert_eq!(req.priority(), 7); assert_eq!(req.max_delay(), Some(Duration::from_secs(10))); assert!( - dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending(), + dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending(), "the second fetch queued a duplicate request" ); @@ -15124,7 +15170,7 @@ mod test { let consumer = producer.consume(); let tight = consumer.fetch_group(5, group::Fetch::default().with_max_delay(Duration::from_secs(1))); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(req.max_delay(), Some(Duration::from_secs(1))); let mut loose = consumer.fetch_group(5, None); assert!(kio::Task::poll(&mut *loose, &kio::Waiter::noop()).is_pending()); @@ -15133,7 +15179,7 @@ mod test { assert!(matches!(tight.await, Err(Error::Old))); assert!(kio::Task::poll(&mut *loose, &kio::Waiter::noop()).is_pending()); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); assert_eq!(req.max_delay(), None); let mut group = req.accept(Info::timed()).unwrap(); group @@ -15153,7 +15199,7 @@ mod test { let first = consumer.fetch_group(5, None); let second = consumer.fetch_group(5, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -15166,7 +15212,7 @@ mod test { let mut retry = consumer.fetch_group(5, None); assert!(kio::Task::poll(&mut *retry, &kio::Waiter::noop()).is_pending()); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -15187,7 +15233,7 @@ mod test { assert!(matches!(pending.await, Err(Error::NotFound))); // And the attempt didn't leak. - let fetch = producer.state.read().fetch.clone(); + let fetch = producer.state.read().fills.clone(); assert!(fetch.read().is_empty()); } @@ -15216,7 +15262,7 @@ mod test { assert!(matches!(consumer.fetch_group(5, None).await, Err(Error::NotFound))); // And it doesn't signal the dynamic handler. - assert!(dynamic.poll_requested_group(&kio::Waiter::noop()).is_pending()); + assert!(dynamic.poll_requested_fill_group(&kio::Waiter::noop()).is_pending()); } /// Mint a track whose groups charge into a bounded [`cache::Pool`]. @@ -15538,7 +15584,7 @@ mod test { // Serve a backfill before the track is accepted, then grow it. let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -15732,7 +15778,7 @@ mod test { // Serve backfill seq 0 before the track is accepted. let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -15794,7 +15840,7 @@ mod test { for sequence in 1..=5u64 { let pending = consumer.fetch_group(sequence, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -16079,7 +16125,7 @@ mod test { // old arrival entry for seq 1 now resolves to it. let pending = consumer.fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -16111,7 +16157,7 @@ mod test { for sequence in [2u64, 3u64] { let pending = consumer.fetch_group(sequence, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -16175,7 +16221,7 @@ mod test { // Re-fetch it: the replacement takes over max_sequence. let pending = consumer.fetch_group(1, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -16216,7 +16262,7 @@ mod test { let pending = consumer.fetch_group(0, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -16282,7 +16328,7 @@ mod test { let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -16328,7 +16374,7 @@ mod test { // Fetch the gap: it lands in the cache and resolves the fetch... let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -16362,7 +16408,7 @@ mod test { // Serve a backfill fetch for an old sequence. let pending = consumer.fetch_group(2, None); let req = dynamic - .requested_group() + .requested_fill_group() .now_or_never() .expect("should not block") .unwrap(); @@ -16574,93 +16620,977 @@ mod test { )); } + /// Every fetch that joined a fill counts as its demand, and the last to leave + /// withdraws it. #[test] - #[cfg(feature = "async")] - fn fetch_request_demand_counts_every_joined_caller() { + fn fill_demand_counts_every_joined_caller() { let producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); let consumer = producer.consume(); let first = consumer.fetch_group(3, None); let second = consumer.fetch_group(3, None); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); - let demand = request.demand(); - assert_eq!(demand.sequence(), 3); - assert!(demand.is_used()); + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("one fill for both fetches"); + }; + assert!(dynamic.poll_requested_fill(&waiter).is_pending(), "the second joined"); + assert_eq!((fill.start(), fill.end()), (Position::group(3), 4)); + assert!(fill.is_used()); drop(first); - assert!(demand.poll_unused(&kio::Waiter::noop()).is_pending()); + assert!(fill.poll_unused(&waiter).is_pending()); drop(second); - assert!(matches!(demand.poll_unused(&kio::Waiter::noop()), Poll::Ready(Ok(())))); - request.reject(Error::Cancel); - assert!(matches!(demand.used().now_or_never(), Some(Err(Error::Cancel)))); - - // The async close helper reports clean acceptance separately from rejection. - for accepted in [true, false] { - let producer = track_producer("test", Info::timed()); - let dynamic = producer.dynamic(); - let consumer = producer.consume(); - let fetch = consumer.fetch_group(3, None); - let request = dynamic.requested_group().now_or_never().unwrap().unwrap(); - let demand = request.demand(); - let group = if accepted { - Some(request.accept(Info::timed()).unwrap()) - } else { - request.reject(Error::NotFound); - None - }; - assert!(matches!( - (accepted, demand.closed().now_or_never()), - (true, Some(Error::Dropped)) | (false, Some(Error::NotFound)) - )); - drop(fetch); - if let Some(mut group) = group { - group.finish().unwrap(); - } - } + assert!(fill.poll_unused(&waiter).is_ready()); + drop(fill); + assert!( + !producer.state.read().drops.contains_key(&3), + "a withdrawn fill records nothing" + ); } + /// A fill accepting a group resolves the fetch from the cache. #[test] - fn accepted_group_request_demand_ends_cleanly() { + fn accepted_fill_resolves_its_fetch() { let producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); let consumer = producer.consume(); let mut fetch = consumer.fetch_group(3, None); let waiter = kio::Waiter::noop(); - let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(&waiter) else { - panic!("the fetch request is ready"); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the fill is ready"); }; - let demand = request.demand(); - let mut group = request.accept(Info::timed()).unwrap(); - - assert!(!demand.is_used()); - assert!(matches!(demand.poll_used(&waiter), Poll::Ready(Err(Error::Dropped)))); - assert!(matches!(demand.poll_unused(&waiter), Poll::Ready(Err(Error::Dropped)))); + let mut group = fill.accept(3, Info::timed()).unwrap(); + assert!(matches!(fill.accept(3, None), Err(Error::Duplicate))); + assert!(matches!(fill.accept(4, None), Err(Error::InvalidRange))); assert!( matches!(kio::Task::poll(&mut *fetch, &waiter), Poll::Ready(Ok(_))), - "the fetch still gets the accepted group" + "the fetch gets the accepted group" ); group.finish().unwrap(); } - #[test] - fn rejected_group_request_demand_keeps_its_reason() { - let producer = track_producer("test", Info::timed()); + /// A feed declared to start at group 2, with groups 2 and 3 written, read from group 0. + #[cfg(feature = "async")] + fn started_late() -> (Producer, Dynamic, Subscriber) { + let mut producer = track_producer("test", Info::timed()); let dynamic = producer.dynamic(); - let consumer = producer.consume(); - let mut fetch = consumer.fetch_group(3, None); + let subscriber = producer.subscribe(replay().with_range(0..)); + producer.start_at(2).unwrap(); + write_group(&producer, 2); + write_group(&producer, 3); + (producer, dynamic, subscriber) + } + + #[cfg(feature = "async")] + fn write_filled(group: Result, millis: u64) { + let mut group = group.unwrap(); + group + .write_frame(Timestamp::from_millis(millis).unwrap(), &b"x"[..]) + .unwrap(); + group.finish().unwrap(); + } + + #[cfg(feature = "async")] + fn drained_drops(subscriber: &mut Subscriber) -> Vec<(u64, u64)> { + let mut dropped = Vec::new(); + while let Poll::Ready((range, _)) = subscriber.poll_drop(&kio::Waiter::noop()) { + dropped.push((range.start, range.end)); + } + dropped + } + + /// What a reader asks for under the feed's start is a miss, asked at once: the reader + /// reads what the handler accepts, and what it refuses is named dropped only once the + /// feed gives up on it too, since it may still arrive late. + #[test] + #[cfg(feature = "async")] + fn a_miss_below_the_start_is_filled() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + assert_eq!(read, [2, 3]); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 2)); + write_filled(fill.accept(0, None), 0); + fill.drop_groups(1..2); + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the filled group is read"); + }; + assert_eq!(group.sequence, 0); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(drained_drops(&mut subscriber).is_empty(), "group 1 may still arrive"); + + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [(1, 2)]); + assert!( + dynamic.poll_requested_fill(&waiter).is_pending(), + "a refusal is not asked again" + ); + } + + /// Groups a fill answered before the reader came back are handed out in the reader's + /// order, so a warm cache keeps a descending reader newest first. + #[test] + #[cfg(feature = "async")] + fn filled_groups_are_read_in_the_readers_order() { + for order in [Order::Descending, Order::Ascending] { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_range(0..).with_order(order)); + producer.start_at(2).unwrap(); + write_group(&producer, 2); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + while let Some(sequence) = fill.next() { + write_filled(fill.accept(sequence, None), sequence); + } + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + let expected = match order { + Order::Descending => [1, 0], + Order::Ascending => [0, 1], + }; + assert_eq!(read, expected, "{order:?}"); + } + } + + /// An ordered reader waiting on a stalled fill gives up the groups it owes once they are + /// stale under its budget, as it skips a stale cached group, and reads on. + #[test] + #[cfg(feature = "async")] + fn a_stalled_fill_goes_stale_under_the_readers_budget() { + let (producer, dynamic, mut subscriber) = started_late(); let waiter = kio::Waiter::noop(); - let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(&waiter) else { - panic!("the fetch request is ready"); + let next = |subscriber: &mut Subscriber| match subscriber.poll_next_group(&waiter) { + Poll::Ready(Ok(Some(group))) => Some(group.sequence), + Poll::Pending => None, + _ => panic!("the read ended"), }; - let demand = request.demand(); - request.reject(Error::NotFound); + assert_eq!(next(&mut subscriber), None, "groups 0 and 1 are being filled"); + // The handler takes the fill and never answers it. + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + elapse(&producer, Duration::from_secs(29)); + assert_eq!(next(&mut subscriber), None, "still within the budget"); + elapse(&producer, Duration::from_secs(2)); + // Group 2 is as stale as the groups below it, superseded by group 3 as long ago. + assert_eq!(next(&mut subscriber), Some(3)); + assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); + assert!(!fill.is_used(), "nobody waits on the stale fill"); + } - assert!(!demand.is_used()); - assert!(matches!(demand.poll_used(&waiter), Poll::Ready(Err(Error::NotFound)))); - assert!(matches!(demand.poll_unused(&waiter), Poll::Ready(Err(Error::NotFound)))); - assert!(matches!( - kio::Task::poll(&mut *fetch, &waiter), - Poll::Ready(Err(Error::NotFound)) - )); + /// Groups arriving out of order inside a stalled fill judge each stretch between them by + /// its own successor: a fresh late group keeps the groups below it owed while the stretches + /// around it go stale. + #[test] + #[cfg(feature = "async")] + fn a_late_fresh_group_keeps_its_stretch_of_a_stalled_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_range(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 0 is being filled" + ); + // The handler takes the fill and never answers it. + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 10)); + write_group(&producer, 2); + write_group(&producer, 8); + elapse(&producer, Duration::from_secs(20)); + write_group(&producer, 5); + elapse(&producer, Duration::from_secs(11)); + + let Poll::Ready(Ok(Some(group))) = subscriber.poll_next_group(&waiter) else { + panic!("groups 0 and 1 went stale behind group 2"); + }; + assert_eq!(group.sequence, 2, "superseded by group 5 only 11s ago"); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 3 is still owed" + ); + for sequence in [3, 4] { + assert!(fill.is_wanted(sequence), "group {sequence} is superseded by group 5"); + } + for sequence in [0, 1, 6, 7, 9] { + assert!(!fill.is_wanted(sequence), "group {sequence} went stale"); + } + } + + /// A fetched group aborted once its readers left is a hole, not an arrival: the fill asked + /// to replace it keeps it wanted. + #[test] + #[cfg(feature = "async")] + fn an_aborted_fetched_group_stays_wanted_by_its_replacement_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + let fetch = producer.consume().fetch_group(3, None); + let Poll::Ready(Ok(first)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + let group = first.accept(3, None).unwrap(); + drop(fetch); + group.abort(Error::Cancel).unwrap(); + drop(first); + + let mut subscriber = producer.subscribe(replay().with_range(0..)); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 0 is being filled" + ); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the aborted group is a miss again"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 10)); + assert!(fill.is_wanted(3), "the aborted group never arrived"); + } + + /// Refusing an evicted group the feed already passed fails the readers waiting on it + /// without naming it dropped, so a later reader of the whole group still gets a fill. + #[test] + #[cfg(feature = "async")] + fn a_refused_group_is_asked_again_by_a_later_reader() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + for sequence in 0..5 { + write_group(&producer, sequence); + } + evict(&producer, 3); + let waiter = kio::Waiter::noop(); + let tail = producer + .consume() + .fetch_group(3, group::Fetch::default().with_frame_start(2)); + let Poll::Ready(Ok(first)) = dynamic.poll_requested_fill(&waiter) else { + panic!("an evicted group is a miss"); + }; + first.refuse_groups(3..4, Error::Unsupported); + assert!(matches!(tail.now_or_never(), Some(Err(Error::Unsupported)))); + drop(first); + + let mut subscriber = producer.subscribe(replay().with_range(3..4)); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 3 is being filled" + ); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the source may still have group 3"); + }; + assert!(fill.is_wanted(3)); + } + + /// A source refusing a fetch as not found answers only that request, such as a head that + /// expired while the tail is kept, so a later reader still gets a fill for the group. + #[test] + #[cfg(feature = "async")] + fn a_source_miss_is_asked_again_by_a_later_reader() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + for sequence in 0..5 { + write_group(&producer, sequence); + } + evict(&producer, 3); + let waiter = kio::Waiter::noop(); + let fetch = producer.consume().fetch_group(3, None); + let Poll::Ready(Ok(first)) = dynamic.poll_requested_fill(&waiter) else { + panic!("an evicted group is a miss"); + }; + first.group(3).reject(Error::NotFound); + assert!(matches!(fetch.now_or_never(), Some(Err(Error::NotFound)))); + drop(first); + + let mut subscriber = producer.subscribe(replay().with_range(3..4)); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 3 is being filled" + ); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the source may still have part of group 3"); + }; + assert!(fill.is_wanted(3)); + } + + /// A backfill cached before its fill publishes the answer is not a live arrival: the reader + /// keeps it owed, so the answer still reaches it. + #[test] + #[cfg(feature = "async")] + fn a_backfill_cached_before_its_answer_stays_owed() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + // The window inside `Fill::accept` between caching the group and publishing the answer. + let _writing = producer + .state + .write() + .ok() + .unwrap() + .insert_group_request(3, 0, None) + .unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(fill.is_wanted(3), "the answer has not reached the reader yet"); + } + + /// An ordered reader taking a filled group leaves the fill at once, so a reader that drops + /// the group without reading on stops holding the fill's source. + #[test] + #[cfg(feature = "async")] + fn an_ordered_reader_leaves_a_filled_group_it_takes() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_range(0..1)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + assert!( + subscriber.poll_next_group(&waiter).is_pending(), + "group 0 is being filled" + ); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + let _writing = fill.accept(0, None).unwrap(); + let Poll::Ready(Ok(Some(group))) = subscriber.poll_next_group(&waiter) else { + panic!("the filled group is read"); + }; + assert_eq!(group.sequence, 0); + drop(group); + assert!(fill.poll_unused(&waiter).is_ready(), "nobody waits on the fill now"); + } + + /// A reader narrowing its ranges leaves the part of its fill it no longer reads: the + /// handler is not asked for it, a fetch of it in flight is cancelled, and neither a bulk + /// refusal nor dropping the fill names it dropped. + #[test] + #[cfg(feature = "async")] + fn a_narrowed_reader_leaves_the_rest_of_its_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!(fill.next(), Some(0)); + assert!(fill.is_wanted(0)); + assert!(fill.poll_unwanted(0, &waiter).is_pending()); + + subscriber.update(ascending(8..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(fill.poll_unwanted(0, &waiter).is_ready(), "nobody reads group 0 now"); + assert!(!fill.is_wanted(0)); + assert!(fill.is_wanted(8)); + assert!(fill.poll_unwanted(8, &waiter).is_pending()); + assert_eq!(fill.next(), Some(8), "nobody reads below 8 any more"); + write_filled(fill.accept(8, None), 8); + assert_eq!(fill.next(), Some(9)); + // Over, so what the fill refuses is a drop rather than a group still to come. + producer.finish().unwrap(); + fill.drop_groups(0..9); + drop(fill); + + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the filled group is read"); + }; + assert_eq!(group.sequence, 8); + assert_eq!(drained_drops(&mut subscriber), [(9, 10)], "only what it still read"); + assert!(producer.state.read().drops.overlapping(&(0..8)).next().is_none()); + } + + /// A group the fill accepted stays wanted until its reader takes it, even once the rest of + /// the fill is answered, so its source keeps writing it meanwhile; narrowing past it + /// before then leaves it. + #[test] + #[cfg(feature = "async")] + fn a_filled_group_is_wanted_until_taken() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + let _writing = [fill.accept(0, None).unwrap(), fill.accept(1, None).unwrap()]; + for sequence in 2..10 { + fill.group(sequence).reject(Error::NotFound); + } + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the first filled group is read"); + }; + assert_eq!(group.sequence, 0); + assert!(!fill.is_wanted(0), "taken: its reader holds the group itself"); + assert!(fill.is_wanted(1), "filled, not taken yet"); + + subscriber.update(ascending(8..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(!fill.is_wanted(1), "nobody reads group 1 now"); + assert!(fill.poll_unwanted(1, &waiter).is_ready()); + } + + /// A session still writing an accepted group stops once every reader narrowed past it, + /// though the fill still owes them later groups. + #[test] + #[cfg(feature = "async")] + fn an_accepted_groups_readers_leave_before_its_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + let group = fill.group(0); + let readers = group.readers(); + let _writing = group.accept(None).unwrap(); + assert!(readers.poll_unused(&waiter).is_pending()); + + subscriber.update(ascending(1..)).unwrap(); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!(readers.poll_unused(&waiter).is_ready(), "nobody reads group 0 now"); + assert!(fill.poll_unused(&waiter).is_pending(), "the fill still owes 1 and up"); + } + + /// A time start before every group held asks no fill below the oldest, since which of + /// those groups it reaches is unknown until they start; another range still asks for its + /// own misses. + #[test] + #[cfg(feature = "async")] + fn a_time_start_before_the_cache_asks_nothing_below_it() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let since = Range { + start: Anchor::Time(at(5)), + end: None, + }; + let mut subscriber = producer.subscribe( + replay() + .with_ranges([Range::groups(0..2), since]) + .with_order(Order::Ascending), + ); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + let mut read = drain_groups(&mut subscriber); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the group range misses its groups"); + }; + assert_eq!( + (fill.start().group, fill.end()), + (0, 2), + "nothing below 10 for the time start" + ); + while let Some(sequence) = fill.next() { + write_filled(fill.accept(sequence, None), sequence); + } + drop(fill); + read.extend(drain_groups(&mut subscriber)); + assert_eq!(read, [10, 0, 1]); + assert!(dynamic.poll_requested_fill(&waiter).is_pending()); + } + + /// A wide fill refused group by group, each with its own error, holds only the answers its + /// readers have yet to take, not one per refusal. + #[test] + #[cfg(feature = "async")] + fn a_wide_fill_forgets_refusals_its_readers_took() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_range(0..).with_order(Order::Ascending)); + producer.start_at(100).unwrap(); + write_group(&producer, 100); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 100)); + for sequence in 0..50 { + assert_eq!(fill.next(), Some(sequence)); + fill.group(sequence).reject(Error::App(sequence as u16)); + let _ = subscriber.poll_recv_group(&waiter); + assert!(fill.answers() <= 1, "group {sequence}: {} answers held", fill.answers()); + } + } + + /// What a fill stopped owing is asked afresh by a reader that wants it later, rather than + /// joining a fetch its handler is cancelling. + #[test] + #[cfg(feature = "async")] + fn a_later_reader_asks_afresh_for_what_a_fill_stopped_owing() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let ascending = |groups| replay().with_range(groups).with_order(Order::Ascending); + let mut narrowed = producer.subscribe(ascending(0..)); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = narrowed.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + narrowed.update(ascending(8..)).unwrap(); + assert!(narrowed.poll_recv_group(&waiter).is_pending()); + + let mut later = producer.subscribe(ascending(0..)); + while let Poll::Ready(Ok(Some(_))) = later.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(again)) = dynamic.poll_requested_fill(&waiter) else { + panic!("what the first fill stopped owing is asked afresh"); + }; + assert_eq!((again.start().group, again.end()), (0, 8)); + assert_eq!(fill.next(), Some(8), "the first fill still owes only 8 and up"); + assert!(!fill.is_wanted(0), "a group stops being wanted for good"); + } + + /// A descending reader's disjoint misses queue newest first, so a handler serving one fill + /// at a time reaches the newer history first. + #[test] + #[cfg(feature = "async")] + fn a_descending_reader_asks_its_newest_miss_first() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = + producer.subscribe(replay().with_ranges([Range::groups(0..2), Range::groups(4..6), Range::groups(8..)])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) { + asked.push((fill.start().group, fill.end())); + } + assert_eq!(asked, [(4, 6), (0, 2)]); + } + + /// A reader forgets the misses it gave up once its window moves past them, as it forgets + /// what it judged, so a feed skipping every other group costs it only the gaps it can + /// still read. + #[test] + #[cfg(feature = "async")] + fn a_reader_forgets_misses_behind_its_window() { + let mut producer = track_producer("test", Info::timed()); + let _dynamic = producer.dynamic(); + let ascending = |groups| Subscription::default().with_range(groups).with_order(Order::Ascending); + let mut subscriber = producer.subscribe(ascending(0..)); + producer.start_at(0).unwrap(); + for sequence in (0..20).step_by(2) { + write_group(&producer, sequence); + drain_groups(&mut subscriber); + elapse(&producer, Duration::from_secs(2)); + drain_groups(&mut subscriber); + } + let given_up = |subscriber: &Subscriber| -> Vec<(u64, u64)> { + let Inner::Plain(cursor) = &subscriber.inner else { + unreachable!("a plain subscription"); + }; + let misses = cursor.misses.as_ref().expect("the reader judged its misses"); + misses.given_up().iter().map(|run| (run.start, run.end)).collect() + }; + assert_eq!(given_up(&subscriber).len(), 9, "each skipped group is given up"); + + subscriber.update(ascending(15..)).unwrap(); + write_group(&producer, 20); + drain_groups(&mut subscriber); + assert_eq!(given_up(&subscriber), [(15, 16), (17, 18)]); + } + + /// A descending reader's miss split by a fill already in flight queues its newer fragment + /// first too. + #[test] + #[cfg(feature = "async")] + fn a_descending_reader_asks_its_newest_fragment_first() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut middle = producer.subscribe(replay().with_range(5..6)); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = middle.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(taken)) = dynamic.poll_requested_fill(&waiter) else { + panic!("group 5 is a miss"); + }; + assert_eq!((taken.start().group, taken.end()), (5, 6)); + + let mut subscriber = producer.subscribe(replay().with_range(0..).with_order(Order::Descending)); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) { + asked.push((fill.start().group, fill.end())); + } + assert_eq!(asked, [(6, 8), (0, 5)]); + } + + /// A group the handler refused that still arrives live is delivered. + #[test] + #[cfg(feature = "async")] + fn a_refused_miss_that_arrives_late_is_read() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + fill.drop_groups(0..2); + write_group(&producer, 1); + let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) else { + panic!("the late group is read"); + }; + assert_eq!(group.sequence, 1); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [(0, 1)]); + } + + /// A refused miss is named dropped as soon as the feed ends, since nothing more arrives, + /// and the read ends with it. + #[test] + #[cfg(feature = "async")] + fn a_refused_miss_is_dropped_once_the_feed_ends() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + fill.drop_groups(0..2); + assert!(subscriber.poll_recv_group(&waiter).is_pending()); + assert!( + drained_drops(&mut subscriber).is_empty(), + "the feed may still deliver them" + ); + producer.finish().unwrap(); + assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); + assert!(matches!(subscriber.poll_recv_group(&waiter), Poll::Ready(Ok(None)))); + } + + /// An end declared ahead of the live edge does not end the feed yet: a group missing + /// below the edge may still arrive, so it is not named dropped. + #[test] + #[cfg(feature = "async")] + fn an_end_ahead_of_the_edge_keeps_a_late_group() { + let mut producer = track_producer("test", Info::timed()); + let mut subscriber = producer.subscribe(replay().with_range(0..)); + for sequence in [0, 1, 2, 4, 5] { + write_group(&producer, sequence); + } + producer.finish_at(Position::group(10)).unwrap(); + assert_eq!(drain_groups(&mut subscriber), [0, 1, 2, 4, 5]); + assert!(drained_drops(&mut subscriber).is_empty(), "group 3 may still arrive"); + write_group(&producer, 3); + assert_eq!(drain_groups(&mut subscriber), [3]); + assert!(drained_drops(&mut subscriber).is_empty()); + } + + /// With no handler to fill it, a miss is named dropped once the feed gives up on it. + #[test] + #[cfg(feature = "async")] + fn a_miss_with_no_handler_is_named_dropped() { + let (producer, dynamic, mut subscriber) = started_late(); + drop(dynamic); + let waiter = kio::Waiter::noop(); + assert!(drained_drops(&mut subscriber).is_empty()); + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(group))) = subscriber.poll_recv_group(&waiter) { + read.push(group.sequence); + } + assert_eq!(read, [2, 3]); + assert!(drained_drops(&mut subscriber).is_empty()); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [(0, 2)]); + } + + /// A fill whose handler leaves before taking it settles by run: a peer that starts its + /// feed far past the reader's floor makes the miss wide, and walking it group by group + /// would never finish. + #[test] + #[cfg(feature = "async")] + fn a_wide_fill_its_handler_left_settles_by_run() { + const WIDE: u64 = 1 << 40; + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_range(0..)); + producer.start_at(WIDE).unwrap(); + write_group(&producer, WIDE); + assert_eq!(drain_groups(&mut subscriber), [WIDE]); + drop(dynamic); + assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); + assert!( + drained_drops(&mut subscriber).is_empty(), + "the feed may still deliver them" + ); + elapse(&producer, Duration::from_secs(31)); + assert_eq!(drained_drops(&mut subscriber), [(0, WIDE)]); + } + + /// A handler refusing a wide fill one group at a time, as a FETCH-capable session does, + /// pays for each refusal rather than for every refusal before it. + #[test] + #[cfg(feature = "async")] + fn a_fill_refused_group_by_group_stays_linear() { + const WIDE: u64 = 1 << 40; + const REFUSED: u64 = 100_000; + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut subscriber = producer.subscribe(replay().with_range(0..)); + producer.start_at(WIDE).unwrap(); + write_group(&producer, WIDE); + assert_eq!(drain_groups(&mut subscriber), [WIDE]); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + for _ in 0..REFUSED { + let sequence = fill.next().unwrap(); + fill.drop_groups(sequence..sequence + 1); + assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); + } + assert_eq!(fill.next(), Some(WIDE - REFUSED - 1)); + } + + /// A reader starting in an earlier group widens a queued fill to the whole of its first + /// group, not just one starting where it does. + #[test] + fn an_earlier_reader_widens_a_queued_fill_to_its_head() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let start = Anchor::Absolute(Position { group: 1, frame: 7 }); + let mut mid = producer.subscribe(replay().with_ranges([Range { start, end: None }])); + let mut whole = producer.subscribe(replay().with_range(0..)); + producer.start_at(4).unwrap(); + write_group(&producer, 4); + let _ = mid.poll_recv_group(&kio::Waiter::noop()); + let _ = whole.poll_recv_group(&kio::Waiter::noop()); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + assert_eq!((fill.start(), fill.end()), (Position { group: 1, frame: 0 }, 4)); + } + + /// Each fill starts at the frame its own range starts from, not the lowest start of + /// the reader's ranges. + #[test] + fn a_fill_starts_at_its_own_ranges_frame() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let at = |group, frame| Anchor::Absolute(Position { group, frame }); + let mut reader = producer.subscribe(replay().with_ranges([ + Range { + start: at(0, 0), + end: Some(at(1, 0)), + }, + Range { + start: at(5, 7), + end: None, + }, + ])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let _ = reader.poll_recv_group(&kio::Waiter::noop()); + + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) { + asked.push((fill.start(), fill.end())); + } + asked.sort(); + assert_eq!( + asked, + [ + (Position { group: 0, frame: 0 }, 1), + (Position { group: 5, frame: 7 }, 8) + ] + ); + } + + /// A range starting inside the group after another range heads its own fill, rather + /// than being read whole as the second group of one. + #[test] + fn an_adjacent_range_starting_mid_group_heads_its_own_fill() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let at = |group, frame| Anchor::Absolute(Position { group, frame }); + let mut reader = producer.subscribe(replay().with_ranges([ + Range { + start: at(4, 0), + end: Some(at(5, 0)), + }, + Range { + start: at(5, 7), + end: None, + }, + ])); + producer.start_at(8).unwrap(); + write_group(&producer, 8); + let _ = reader.poll_recv_group(&kio::Waiter::noop()); + + let mut asked = Vec::new(); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) { + asked.push((fill.start(), fill.end())); + } + asked.sort(); + assert_eq!( + asked, + [ + (Position { group: 4, frame: 0 }, 5), + (Position { group: 5, frame: 7 }, 8) + ] + ); + } + + /// A descending reader joining a queued fill makes it newest first, as any descending + /// subscriber does a subscription. + #[test] + fn a_descending_reader_turns_a_queued_fill_newest_first() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + let mut ascending = producer.subscribe(replay().with_range(0..).with_order(Order::Ascending)); + let mut descending = producer.subscribe(replay().with_range(0..)); + producer.start_at(4).unwrap(); + write_group(&producer, 4); + let _ = ascending.poll_recv_group(&kio::Waiter::noop()); + let _ = descending.poll_recv_group(&kio::Waiter::noop()); + + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { + panic!("the miss below the start is asked"); + }; + assert_eq!((fill.start().group, fill.end()), (0, 4)); + assert_eq!(fill.order(), Order::Descending); + assert!( + dynamic.poll_requested_fill(&kio::Waiter::noop()).is_pending(), + "both readers share the fill" + ); + } + + /// An ordered reader waits on a fill for its miss rather than skipping past it. + #[test] + #[cfg(feature = "async")] + fn an_ordered_read_waits_on_a_fill() { + let (_producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + let mut next = || match subscriber.poll_next_group(&waiter) { + Poll::Ready(Ok(Some(group))) => Some(group.sequence), + Poll::Pending => None, + _ => panic!("the read ended"), + }; + assert_eq!(next(), None, "group 0 is being filled"); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("below the start is a miss"); + }; + write_filled(fill.accept(0, None), 0); + assert_eq!(next(), Some(0)); + assert_eq!(next(), None, "group 1 is being filled"); + fill.drop_groups(1..2); + assert_eq!(next(), Some(2)); + assert_eq!(next(), Some(3)); + } + + /// A fill nobody waits on any more is withdrawn: it names nothing dropped, so a later + /// reader can still ask for the same history. + #[test] + #[cfg(feature = "async")] + fn a_withdrawn_fill_names_nothing_dropped() { + let (producer, dynamic, mut subscriber) = started_late(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = subscriber.poll_recv_group(&waiter) {} + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the gap is a miss"); + }; + drop(subscriber); + assert!(!fill.is_used()); + drop(fill); + assert!(drops(&producer).is_empty()); + } + + /// Fetch group 3 and drop it through the fill it queues, returning how the fetch ended. + fn drop_through_fill(producer: &Producer, dynamic: &Dynamic, drop_all: bool) -> Error { + let consumer = producer.consume(); + let mut fetch = consumer.fetch_group(3, None); + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the fill is ready"); + }; + match drop_all { + true => fill.drop_groups(0..10), + false => drop(fill), + } + match kio::Task::poll(&mut *fetch, &waiter) { + Poll::Ready(Err(err)) => err, + _ => panic!("the fetch fails"), + } + } + + fn drops(producer: &Producer) -> Vec> { + producer + .state + .read() + .drops + .iter() + .map(|(range, _)| range.clone()) + .collect() + } + + /// A group the source no longer has fails its fetch, and is named dropped for good only + /// once the live feed gave up on it too: a fetch answered before a late group header + /// arrives says nothing about the feed. + #[moq_clock::test] + async fn dropped_fill_group_fails_its_fetch() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + write_group(&producer, 5); + assert!(matches!(drop_through_fill(&producer, &dynamic, true), Error::NotFound)); + assert!(drops(&producer).is_empty(), "the feed may still deliver 3"); + + moq_clock::sim::advance(Duration::from_secs(60)).await; + assert!(matches!(drop_through_fill(&producer, &dynamic, true), Error::NotFound)); + assert_eq!(drops(&producer), vec![3..4], "clipped to the fill"); + } + + /// Dropping a fill a reader still waits on names what it left as dropped. + #[moq_clock::test] + async fn fill_dropped_unanswered_names_the_rest_dropped() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.dynamic(); + write_group(&producer, 5); + moq_clock::sim::advance(Duration::from_secs(60)).await; + assert!(matches!(drop_through_fill(&producer, &dynamic, false), Error::Dropped)); + assert_eq!(drops(&producer), vec![3..4]); } #[moq_clock::test] @@ -17016,7 +17946,7 @@ mod test { let consumer = producer.consume(); let mut early = producer.subscribe(replay().with_ranges([Range::live()])); let _fetching = consumer.fetch_group(5, None); - let req = dynamic.requested_group().now_or_never().unwrap().unwrap(); + let req = dynamic.requested_fill_group().now_or_never().unwrap().unwrap(); req.accept(None).unwrap().finish().unwrap(); producer .create_group(group::Info { sequence: 0 }) @@ -17518,7 +18448,8 @@ mod test { live.start_at(1).unwrap(); live.write_frame(Timestamp::ZERO, &b"delta"[..]).unwrap(); let _fetch = consumer.fetch_group(0, None); - let mut head = dynamic.requested_group().await.unwrap().accept(Info::timed()).unwrap(); + let fill = dynamic.requested_fill().await.unwrap(); + let mut head = fill.accept(0, Info::timed()).unwrap(); head.write_frame(Timestamp::ZERO, &b"snapshot"[..]).unwrap(); producer.drop_groups(0..1, StreamError::App(5)); diff --git a/rs/moq-net/src/model/track/demand.rs b/rs/moq-net/src/model/track/demand.rs index 796b065bd1b..63f93789511 100644 --- a/rs/moq-net/src/model/track/demand.rs +++ b/rs/moq-net/src/model/track/demand.rs @@ -18,6 +18,7 @@ use ranges::Ranges; pub(super) struct Subscriptions { count: usize, priority: BTreeMap, + // Keyed by budget, so `None` counts as the widest of all. max_delay: BTreeMap, ranges: Ranges, descending: usize, @@ -32,7 +33,7 @@ pub(super) struct Subscriptions { } /// Everything the aggregate holds besides its ranges. -type Scalars = (i8, Duration, Order); +type Scalars = (i8, Option, Order); impl Subscriptions { pub(super) fn combined(&self) -> Option { @@ -56,7 +57,7 @@ impl Subscriptions { (self.count != 0).then(|| { ( *self.priority.last_key_value().unwrap().0, - *self.max_delay.last_key_value().unwrap().0, + Some(*self.max_delay.last_key_value().unwrap().0).filter(|budget| *budget != Duration::MAX), if self.descending == 0 { Order::Ascending } else { @@ -140,7 +141,7 @@ impl Subscriptions { let moved = old.map(|old| &old.ranges) != new.map(|new| &new.ranges); if let Some(new) = new { add(&mut self.priority, new.priority); - add(&mut self.max_delay, new.max_delay); + add(&mut self.max_delay, new.budget()); if moved { for range in &new.ranges { reshaped |= self.ranges.add(*range); @@ -151,7 +152,7 @@ impl Subscriptions { } if let Some(old) = old { remove(&mut self.priority, old.priority); - remove(&mut self.max_delay, old.max_delay); + remove(&mut self.max_delay, old.budget()); if moved { for range in &old.ranges { reshaped |= self.ranges.remove(*range); @@ -318,7 +319,7 @@ mod tests { .collect(); let next = Subscription { priority: rng.random_range(0..8), - max_delay: Duration::from_millis(rng.random_range(0..8)), + max_delay: Some(Duration::from_millis(rng.random_range(0..8))), ranges, order: if rng.random_bool(0.5) { Order::Ascending diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs new file mode 100644 index 00000000000..392e62d9db2 --- /dev/null +++ b/rs/moq-net/src/model/track/fill.rs @@ -0,0 +1,1037 @@ +//! Fills: the groups readers miss, asked of a [`Dynamic`](super::Dynamic) handler by range. +//! +//! A miss is a sequence a reader wants that the cache doesn't hold and the live feed will +//! never deliver. A reader joins the fill already covering it, or queues a new one, and a +//! handler serves each fill it takes as one bounded run: [`Fill::accept`] for each group it +//! has, [`Fill::drop_groups`] for the rest. + +use std::{ + collections::{HashMap, VecDeque}, + ops, + sync::Arc, + task::Poll, + time::Duration, +}; + +use rangemap::{RangeMap, RangeSet}; + +use super::{Info, Order, Position, TrackState, clamp_max_delay}; +use crate::{Error, Result, StreamError, group}; + +/// The fills a track's readers asked for, queued or taken by a handler, under one lock. +#[derive(Default)] +pub(in crate::model) struct FillState { + // Live `Dynamic` handles: a fill queues only while one can take it. + handlers: usize, + next: u64, + // Every fill not yet ended, queued or taken, by id. + pending: HashMap, + // The queued subset, oldest first. + queue: VecDeque, + // The fill each unresolved sequence waits on, so a second reader joins it rather than + // asking again. + covers: RangeMap, +} + +/// One fill until it ends. +struct Pending { + start: Position, + end: u64, + priority: i8, + max_delay: Option, + order: Order, + queued: bool, + // How many joins still read each sequence: one a reader narrowed away is no longer owed. + wants: RangeMap, + // Readers hold consumers, so it counts the fill's demand; the handler's copy says what + // became of each sequence. + result: kio::Producer, +} + +impl Pending { + /// Count one more join reading `groups`. + fn want(&mut self, groups: ops::Range) { + let mut counts: Vec<(ops::Range, u32)> = self + .wants + .overlapping(&groups) + .map(|(run, count)| (run.start.max(groups.start)..run.end.min(groups.end), count + 1)) + .collect(); + counts.extend(self.wants.gaps(&groups).map(|gap| (gap, 1))); + for (run, count) in counts { + self.wants.insert(run, count); + } + } + + /// Count one join out of reading `groups`, which it counted in. Returns the runs nobody + /// reads any more. + fn unwant(&mut self, groups: ops::Range) -> Vec> { + let counts: Vec<(ops::Range, u32)> = self + .wants + .overlapping(&groups) + .map(|(run, count)| (run.start.max(groups.start)..run.end.min(groups.end), count - 1)) + .collect(); + let mut unwanted = Vec::new(); + for (run, count) in counts { + match count { + 0 => { + self.wants.remove(run.clone()); + unwanted.push(run); + } + count => self.wants.insert(run, count), + } + } + unwanted + } + + /// The runs of `span` a join still reads. + fn wanted(&self, span: &ops::Range) -> RangeSet { + self.wants + .overlapping(span) + .map(|(run, _)| run.start.max(span.start)..run.end.min(span.end)) + .collect() + } +} + +/// What became of a fill's sequences, shared with every reader that joined it. +#[derive(Default)] +pub(crate) struct Outcome { + accepted: RangeSet, + // Why each refused run was refused; refusing a sequence again replaces why. + refused: RangeMap, + refusals: u64, + // Accepted or refused, kept as answers arrive so a fill answered group by group costs + // the runs each answer touches, not every answer before it. + resolved: RangeSet, + // The budget the fill asked its handler for, so a looser reader can tell that an + // [`Error::Old`] was not its own. + asked: Option, +} + +impl Outcome { + /// Why `sequence` was refused, if it was. + pub(crate) fn refused(&self, sequence: u64) -> Option<&Error> { + self.refused.get(&sequence).map(|refusal| &refusal.err) + } + + pub(super) fn is_accepted(&self, sequence: u64) -> bool { + self.accepted.contains(&sequence) + } + + pub(super) fn asked(&self) -> Option { + self.asked + } + + /// What was answered within `wanted`: accepted, or refused with why and the budget + /// the fill asked with. + #[expect( + clippy::type_complexity, + reason = "each answer pairs a run with its outcome; naming it would only move the type" + )] + pub(super) fn within( + &self, + wanted: &RangeSet, + ) -> Vec<(ops::Range, std::result::Result<(), (Error, Option)>)> { + let mut answers = Vec::new(); + for run in wanted.iter() { + for accepted in self.accepted.overlapping(run) { + answers.push((accepted.start.max(run.start)..accepted.end.min(run.end), Ok(()))); + } + for (refused, refusal) in self.refused.overlapping(run) { + let groups = refused.start.max(run.start)..refused.end.min(run.end); + answers.push((groups, Err((refusal.err.clone(), self.asked)))); + } + } + answers + } + + fn accept(&mut self, groups: ops::Range) { + self.accepted.insert(groups.clone()); + self.resolved.insert(groups); + } + + fn refuse(&mut self, groups: ops::Range, err: Error) { + if groups.is_empty() { + return; + } + let id = self.refusals; + self.refusals += 1; + self.refused.insert(groups.clone(), Refusal { id, err }); + self.resolved.insert(groups); + } +} + +/// One refusal, equal only to itself: the map keeps a run it refused whole, but never merges +/// two refusals whose errors may differ. +#[derive(Clone)] +struct Refusal { + id: u64, + err: Error, +} + +impl PartialEq for Refusal { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for Refusal {} + +/// A reader's request: the run `start.group..end`, from `start.frame` in its first group. +pub(super) struct Ask { + pub start: Position, + pub end: u64, + pub priority: i8, + pub max_delay: Option, + pub order: Order, +} + +/// A reader's place in one fill: the part of it the reader wants. +pub(super) struct Join { + fills: kio::Shared, + id: u64, + /// The sequences of the fill this reader asked for. + pub groups: ops::Range, + // The part of `groups` it still reads, counted in the fill's wants. + held: RangeSet, + // Counts this reader in the fill's demand; `None` only while dropping. + outcome: Option>, + attempt: kio::ProducerWeak, +} + +impl Join { + pub(super) fn outcome(&self) -> &kio::Consumer { + self.outcome.as_ref().expect("held until dropped") + } +} + +impl Drop for Join { + fn drop(&mut self) { + // Joins take the lock, so leaving under it is atomic with one: a joiner either keeps + // the fill wanted, or finds it withdrawn and asks afresh rather than joining a fill + // its handler is about to drop. + let mut fills = self.fills.lock(); + drop(self.outcome.take()); + if !fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { + return; + } + for run in std::mem::take(&mut self.held).iter() { + fills.unwant(self.id, run.clone()); + } + if !self.attempt.is_used() { + fills.withdraw(self.id); + } + } +} + +impl Join { + fn is_ours(&self, pending: &Pending) -> bool { + pending.result.weak().same_channel(&self.attempt) + } + + /// Whether the reader holds none of the fill any more. + pub(super) fn is_empty(&self) -> bool { + self.held.is_empty() + } + + /// Stop reading `lost`, so the fill no longer owes this reader any of it. + pub(super) fn release(&mut self, lost: &RangeSet) { + let released: Vec> = lost + .overlapping(&self.groups) + .flat_map(|run| { + self.held + .overlapping(run) + .map(move |held| held.start.max(run.start)..held.end.min(run.end)) + }) + .collect(); + if released.is_empty() { + return; + } + for run in &released { + self.held.remove(run.clone()); + } + let mut fills = self.fills.lock(); + if !fills.pending.get(&self.id).is_some_and(|pending| self.is_ours(pending)) { + return; + } + for run in released { + fills.unwant(self.id, run); + } + } +} + +impl FillState { + pub(super) fn add_handler(&mut self) { + self.handlers += 1; + } + + /// Count a handler out. The last one leaving drops what it never took, so every reader + /// waiting on those fills finds them gone; a fill already taken stays with its handler. + pub(super) fn remove_handler(&mut self) { + self.handlers -= 1; + if self.handlers > 0 { + return; + } + for id in std::mem::take(&mut self.queue) { + self.end(id); + } + } + + pub(super) fn has_handlers(&self) -> bool { + self.handlers > 0 + } + + pub(super) fn has_queued(&self) -> bool { + !self.queue.is_empty() + } + + /// Whether no fill is pending, queued or taken. + #[cfg(all(test, feature = "async"))] + pub(super) fn is_empty(&self) -> bool { + self.pending.is_empty() && self.queue.is_empty() && self.covers.is_empty() + } + + /// Join every fill covering `ask`, queueing a new one for each run none covers. Returns + /// the joins, and the runs nobody can fill: no handler is alive to take a new fill. + pub(super) fn join(&mut self, shared: &kio::Shared, ask: &Ask) -> (Vec, Vec>) { + let span = ask.start.group..ask.end; + let mut joins = Vec::new(); + let mut unfillable = Vec::new(); + if span.is_empty() { + return (joins, unfillable); + } + let covered: Vec<(ops::Range, u64)> = self + .covers + .overlapping(&span) + .map(|(groups, id)| (groups.start.max(span.start)..groups.end.min(span.end), *id)) + .collect(); + let mut gaps: Vec> = self.covers.gaps(&span).collect(); + // Queued in the reader's order, so a descending reader's newest fragment goes first. + if ask.order == Order::Descending { + gaps.reverse(); + } + for (groups, id) in covered { + let pending = self + .pending + .get_mut(&id) + .expect("a covered sequence names a pending fill"); + // Widened only while queued: once taken, the handler holds what it was asked. + if pending.queued { + pending.priority = pending.priority.max(ask.priority); + pending.max_delay = looser(pending.max_delay, ask.max_delay); + // Any descending reader makes it newest first, as it does a subscription. + if ask.order == Order::Descending { + pending.order = Order::Descending; + } + // A reader starting in an earlier group wants this one whole. + if groups.start == pending.start.group { + let frame = match ask.start.group == groups.start { + true => ask.start.frame, + false => 0, + }; + pending.start.frame = pending.start.frame.min(frame); + } + } + pending.want(groups.clone()); + joins.push(Join { + fills: shared.clone(), + id, + held: RangeSet::from_iter([groups.clone()]), + groups, + outcome: Some(pending.result.consume()), + attempt: pending.result.weak(), + }); + } + for groups in gaps { + if self.handlers == 0 { + unfillable.push(groups); + continue; + } + let id = self.next; + self.next += 1; + let start = match groups.start == ask.start.group { + true => ask.start, + false => Position::group(groups.start), + }; + let result = kio::Producer::::default(); + joins.push(Join { + fills: shared.clone(), + id, + groups: groups.clone(), + held: RangeSet::from_iter([groups.clone()]), + outcome: Some(result.consume()), + attempt: result.weak(), + }); + self.covers.insert(groups.clone(), id); + self.pending.insert( + id, + Pending { + start, + end: groups.end, + priority: ask.priority, + max_delay: ask.max_delay, + order: ask.order, + queued: true, + wants: RangeMap::from_iter([(groups.clone(), 1)]), + result, + }, + ); + self.queue.push_back(id); + } + (joins, unfillable) + } + + /// Count a join out of reading `groups` of fill `id`. What nobody reads any more is no + /// longer routed to it: a reader wanting it later asks afresh rather than joining a fetch + /// being cancelled, so a sequence a fill stops owing stays unowed. Its answers are + /// forgotten too, so a wide fill holds the answers its readers have yet to take, not one + /// per group its source answered. + fn unwant(&mut self, id: u64, groups: ops::Range) { + let Some(pending) = self.pending.get_mut(&id) else { + return; + }; + let unwanted = pending.unwant(groups); + let answered = { + let outcome = pending.result.read(); + unwanted + .iter() + .any(|run| outcome.refused.overlaps(run) || outcome.accepted.overlaps(run)) + }; + if answered && let Ok(mut outcome) = pending.result.write() { + for run in &unwanted { + outcome.refused.remove(run.clone()); + outcome.accepted.remove(run.clone()); + } + } + for run in unwanted { + for range in self.routed(id, &run) { + self.covers.remove(range); + } + } + } + + /// Whether a join of fill `id` still reads `sequence`. + fn wants(&self, id: u64, sequence: u64) -> bool { + self.pending + .get(&id) + .is_some_and(|pending| pending.wants.contains_key(&sequence)) + } + + /// Take the oldest queued fill, still pending (joinable) until it ends. + fn pop(&mut self) -> Option { + let id = self.queue.pop_front()?; + let pending = self.pending.get_mut(&id).expect("a queued fill is pending"); + pending.queued = false; + Some(id) + } + + /// Drop fill `id`: nobody wants it, so it records nothing. + fn withdraw(&mut self, id: u64) { + let Some(pending) = self.pending.get(&id) else { + return; + }; + if pending.queued { + self.queue.retain(|queued| *queued != id); + } + self.end(id); + } + + /// The parts of `groups` routed to fill `id`. + fn routed(&self, id: u64, groups: &ops::Range) -> Vec> { + self.covers + .overlapping(groups) + .filter(|(_, covering)| **covering == id) + .map(|(range, _)| range.start.max(groups.start)..range.end.min(groups.end)) + .collect() + } + + /// Forget fill `id`, closing it for its readers. + fn end(&mut self, id: u64) { + let Some(pending) = self.pending.remove(&id) else { + return; + }; + for range in self.routed(id, &(pending.start.group..pending.end)) { + self.covers.remove(range); + } + } +} + +/// Stop routing readers wanting `groups` to fill `id`: they were answered. Reads first, so +/// an answer that changes nothing wakes no handler parked on the queue. +fn uncover(fills: &kio::Shared, id: u64, groups: ops::Range) { + let mut fills = fills.lock(); + let routed = fills.routed(id, &groups); + for range in routed { + fills.covers.remove(range); + } +} + +/// The more tolerant of two budgets: `None` is publisher max-age alone, which no explicit +/// budget can relax. +fn looser(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.max(b)), + _ => None, + } +} + +/// Pop the next queued fill, or surface the track's abort once none is queued. +pub(super) fn poll_requested( + state: &kio::Producer, + fills: &kio::Shared, + waiter: &kio::Waiter, +) -> Poll> { + // Prefer serving a queued fill, even if the track has since aborted. + if let Poll::Ready(mut guard) = fills.poll(waiter, |fills| match fills.has_queued() { + true => Poll::Ready(()), + false => Poll::Pending, + }) { + let id = guard.pop().expect("predicate guaranteed a fill"); + let pending = guard.pending.get(&id).expect("popped fill is pending"); + let (start, end, priority, max_delay, order) = ( + pending.start, + pending.end, + pending.priority, + pending.max_delay, + pending.order, + ); + let result = pending.result.clone(); + drop(guard); + // Built with the fills unlocked, so no other lock nests under theirs. + let inner = Inner { + state: state.clone(), + fills: fills.clone(), + id, + start, + end, + priority, + max_delay, + order, + result, + }; + // What it asked for decides whose `Old` it is. + if let Ok(mut outcome) = inner.result.write() { + outcome.asked = inner.max_delay; + } + return Poll::Ready(Ok(Fill { inner: Arc::new(inner) })); + } + + // No fill queued: surface a track abort so the handler loop can exit. + match state.poll_ref(waiter, |state| match &state.abort { + Some(err) => Poll::Ready(err.clone()), + None => Poll::Pending, + }) { + Poll::Ready(Ok(err)) => Poll::Ready(Err(err)), + Poll::Ready(Err(closed)) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), + Poll::Pending => Poll::Pending, + } +} + +/// A run of groups readers miss, for a [`Dynamic`](super::Dynamic) handler to serve. +/// +/// It names `start.group` up to the exclusive [`end`](Self::end), the first group from +/// [`start`](Self::start)'s frame. The handler calls [`accept`](Self::accept) for each group +/// it has and [`drop_groups`](Self::drop_groups) for the rest. Dropping the fill names +/// whatever is left as dropped while a reader still wants it; once nobody does, the fill is +/// withdrawn and records nothing, so stop serving it when [`unused`](Self::unused) resolves. +/// A reader narrowing its ranges may leave a single group, so stop serving that one when +/// [`unwanted`](Self::unwanted) resolves and move on to [`next`](Self::next). +pub struct Fill { + inner: Arc, +} + +/// The state a [`Fill`] and its per-group handles share. The last one dropping settles what +/// was left. +struct Inner { + state: kio::Producer, + fills: kio::Shared, + id: u64, + start: Position, + end: u64, + priority: i8, + max_delay: Option, + order: Order, + result: kio::Producer, +} + +impl Fill { + /// The first group to fill, and the frame within it to start from. + pub fn start(&self) -> Position { + self.inner.start + } + + /// The frame group `sequence` starts from: [`start`](Self::start)'s frame for the first + /// group, 0 for the rest. + pub fn frame_start(&self, sequence: u64) -> u64 { + self.inner.frame_start(sequence) + } + + /// The exclusive end group. + pub fn end(&self) -> u64 { + self.inner.end + } + + /// The most urgent delivery priority among the readers waiting on it. + pub fn priority(&self) -> i8 { + self.inner.priority + } + + /// The most tolerant age budget among the readers waiting on it, or `None` for the + /// publisher's max age alone. Each reader still enforces its own. + pub fn max_delay(&self) -> Option { + self.inner.max_delay + } + + /// Which end to fill first: [`Order::Descending`] is newest first. + pub fn order(&self) -> Order { + self.inner.order + } + + /// Insert group `sequence` into the track's cache, handing it to the readers waiting on + /// it, and return a [`group::Producer`] to fill. + /// + /// The group starts at [`start`](Self::start)'s frame when it is the first group, so the + /// frames written keep the indices they have in the group. `info` sets the track's + /// [`Info`] if it hasn't been accepted yet (a fetch with no live subscription), and is + /// ignored otherwise. Errors with [`Error::InvalidRange`] outside the fill, + /// [`Error::Duplicate`] if the group was already answered or cached, or the track's abort + /// error. + pub fn accept(&self, sequence: u64, info: impl Into>) -> Result { + self.inner.accept(sequence, info.into()) + } + + /// Name `groups` as never to be delivered: the source no longer has them. Readers stop + /// waiting for them, and the track records them so nobody asks again. Only the groups a + /// reader still reads count; the rest of the fill was never owed. + pub fn drop_groups(&self, groups: ops::Range) { + self.inner.drop_groups(groups, Error::NotFound); + } + + /// Refuse `groups` with `err` without saying the source lost them, as when serving them + /// failed for now or this request asks what the source can't serve. The readers waiting + /// now stop waiting, and a later reader asks again. Only the groups a reader still reads + /// count, as with [`Self::drop_groups`]. + pub fn refuse_groups(&self, groups: ops::Range, err: Error) { + let owed = self.inner.owed(groups); + self.inner.refuse(&owed, err); + } + + /// Whether a reader still waits on this fill. + pub fn is_used(&self) -> bool { + self.inner.result.is_used() + } + + /// Wait until no reader waits on this fill: it is withdrawn, so stop serving it. + #[cfg(feature = "async")] + pub async fn unused(&self) { + kio::wait(|waiter| self.poll_unused(waiter)).await + } + + /// Poll until no reader waits on this fill. + pub fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + self.inner.result.poll_unused(waiter).map(|_| ()) + } + + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. A group stops being + /// wanted for good; once accepted and taken, [`group::Producer::demand`](crate::group::Producer::demand) + /// says who reads it. + pub fn is_wanted(&self, sequence: u64) -> bool { + self.inner.is_wanted(sequence) + } + + /// Wait until no reader waits for group `sequence` from this fill, so stop serving it. + #[cfg(feature = "async")] + pub async fn unwanted(&self, sequence: u64) { + kio::wait(|waiter| self.poll_unwanted(sequence, waiter)).await + } + + /// Poll until no reader waits for group `sequence` from this fill. + pub fn poll_unwanted(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { + self.inner.poll_unwanted(sequence, waiter) + } + + /// How many answer runs the fill still holds for its readers. + #[cfg(test)] + pub(super) fn answers(&self) -> usize { + let outcome = self.inner.result.read(); + outcome.refused.iter().count() + outcome.accepted.iter().count() + } + + /// The next group still owed in the fill's order: a reader still reads it, and it was + /// neither answered nor delivered live since it was asked. `None` once none is owed. + pub fn next(&self) -> Option { + loop { + let wanted = self.inner.wanted(); + let sequence = { + let outcome = self.inner.result.read(); + let mut gaps = wanted.iter().flat_map(|run| outcome.resolved.gaps(run)); + match self.inner.order { + Order::Ascending => gaps.next().map(|gap| gap.start), + Order::Descending => gaps.last().map(|gap| gap.end - 1), + } + }?; + // The live feed may have delivered it since: answered by the cache, not the source. + let cached = self + .inner + .state + .read() + .covering_group(sequence, self.inner.frame_start(sequence)) + .is_some(); + if !cached { + return Some(sequence); + } + let Ok(mut outcome) = self.inner.result.write() else { + return Some(sequence); + }; + outcome.accept(sequence..sequence + 1); + drop(outcome); + uncover(&self.inner.fills, self.inner.id, sequence..sequence + 1); + } + } + + /// Refuse every group still owed with `err`, which says nothing about whether the source + /// has them, so nothing is recorded dropped. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn refuse(&self, err: Error) { + let unresolved = self.inner.unresolved(self.inner.start.group..self.inner.end); + self.inner.refuse(&unresolved, err); + } + + /// A handle on group `sequence` alone, for a session serving the fill one group at a time. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn group(&self, sequence: u64) -> GroupFill { + GroupFill { + inner: self.inner.clone(), + sequence, + frame_start: match sequence == self.inner.start.group { + true => self.inner.start.frame, + false => 0, + }, + } + } +} + +impl Inner { + fn contains(&self, sequence: u64) -> bool { + (self.start.group..self.end).contains(&sequence) + } + + fn is_wanted(&self, sequence: u64) -> bool { + self.result.is_used() && self.fills.read().wants(self.id, sequence) + } + + fn poll_unwanted(&self, sequence: u64, waiter: &kio::Waiter) -> Poll<()> { + if self.result.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + self.fills + .poll(waiter, |fills| match fills.wants(self.id, sequence) { + true => Poll::Pending, + false => Poll::Ready(()), + }) + .map(drop) + } + + /// The runs of the fill a reader still reads, read before any other lock. + fn wanted(&self) -> RangeSet { + let span = self.start.group..self.end; + let fills = self.fills.lock(); + fills + .pending + .get(&self.id) + .map_or_else(RangeSet::new, |pending| pending.wanted(&span)) + } + + fn frame_start(&self, sequence: u64) -> u64 { + match sequence == self.start.group { + true => self.start.frame, + false => 0, + } + } + + fn accept(&self, sequence: u64, info: Option) -> Result { + if !self.contains(sequence) { + return Err(Error::InvalidRange); + } + let Some(end) = sequence.checked_add(1) else { + return Err(Error::InvalidRange); + }; + if self.result.read().resolved.contains(&sequence) { + return Err(Error::Duplicate); + } + // Cache the group before answering: readers resolve an accept through the cache. + let res = TrackState::modify(&self.state) + .and_then(|mut state| state.insert_group_request(sequence, self.frame_start(sequence), info)); + if let Ok(mut outcome) = self.result.write() { + outcome.accept(sequence..end); + } + uncover(&self.fills, self.id, sequence..end); + res + } + + /// Refuse `groups` with `err`, recording them in the track's drops when the source no + /// longer has them, which binds every reader. Only what a reader still reads: a handler + /// refusing the whole fill never checked the rest. + fn drop_groups(&self, groups: ops::Range, err: Error) { + let unresolved = self.owed(groups); + if unresolved.is_empty() { + return; + } + if let Ok(mut state) = TrackState::modify(&self.state) { + for groups in unresolved.iter() { + state.drop_unfed(groups.clone(), StreamError::from(&err)); + } + } + self.refuse(&unresolved, err); + } + + /// The part of `groups` inside the fill that a reader still reads and nothing answered yet. + fn owed(&self, groups: ops::Range) -> RangeSet { + let groups = groups.start.max(self.start.group)..groups.end.min(self.end); + let mut owed = RangeSet::new(); + if groups.is_empty() { + return owed; + } + let wanted = self.wanted(); + let outcome = self.result.read(); + for run in wanted.overlapping(&groups) { + for gap in outcome + .resolved + .gaps(&(run.start.max(groups.start)..run.end.min(groups.end))) + { + owed.insert(gap); + } + } + owed + } + + /// The part of `groups` nothing answered yet. + fn unresolved(&self, groups: ops::Range) -> RangeSet { + self.result.read().resolved.gaps(&groups).collect() + } + + fn refuse(&self, groups: &RangeSet, err: Error) { + if groups.is_empty() { + return; + } + if let Ok(mut outcome) = self.result.write() { + for groups in groups.iter() { + outcome.refuse(groups.clone(), err.clone()); + } + } + for groups in groups.iter() { + uncover(&self.fills, self.id, groups.clone()); + } + } + + /// Whether an [`Error::Old`] answer is the publisher's max age, which binds every reader, + /// rather than this fill's own budget, which a more tolerant reader may exceed. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + fn old_binds_everyone(&self) -> bool { + let Some(max_delay) = self.max_delay else { + return true; + }; + let bound = self.state.read().max_age_bound(); + bound.is_some_and(|bound| clamp_max_delay(max_delay, Some(bound)) >= bound) + } + + /// Refuse group `sequence` with `err`, as its source answered. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + fn reject(&self, sequence: u64, err: Error) { + let Some(end) = sequence.checked_add(1) else { + return; + }; + match err { + // A datagram is never cached, so no request ever gets it. + Error::NotFetchable => self.drop_groups(sequence..end, err), + // Stale under the fill's budget, and so is every group older than it: a timed + // track's starts never rewind. Only the publisher's max age binds every reader; + // under the fill's own budget they are withdrawn, for a reader with a wider one. + Error::Old => { + let older = self.start.group..end; + match self.old_binds_everyone() { + true => self.drop_groups(older, Error::Old), + false => { + let unresolved = self.unresolved(older); + self.refuse(&unresolved, Error::Old); + } + } + } + // Anything else, a miss included, answers only this request: one starting at + // another frame may still be served, such as the tail of a group whose head + // expired. Refused for now, recorded nowhere. + err => { + let unresolved = self.unresolved(sequence..end); + self.refuse(&unresolved, err); + } + } + } +} + +impl Drop for Inner { + fn drop(&mut self) { + // Whatever the handler left unanswered is named dropped, but only while a reader still + // reads it: once demand left, the fill was withdrawn and records nothing. A group cached + // since (the live feed delivered it) is no drop at all. + let wanted = self.wanted(); + let left: RangeSet = { + let outcome = self.result.read(); + wanted.iter().flat_map(|run| outcome.resolved.gaps(run)).collect() + }; + if !left.is_empty() && self.result.is_used() { + if let Ok(mut state) = TrackState::modify(&self.state) { + // Unanswered is a miss downstream, as an explicit `drop_groups` would say. + for groups in left.iter() { + state.drop_unfed(groups.clone(), StreamError::NotFound); + } + } + if let Ok(mut outcome) = self.result.write() { + for groups in left.iter() { + outcome.refuse(groups.clone(), Error::Dropped); + } + } + } + self.fills.lock().end(self.id); + } +} + +impl TrackState { + /// Record `groups` as dropped, as a fill's source answered, except what the cache holds + /// or the live feed may still deliver: a fill refused is no verdict on the feed. + fn drop_unfed(&mut self, groups: ops::Range, err: StreamError) { + // Until the serving session resolves where the feed starts, it may deliver anything. + if self.start_pending { + return; + } + let now = self.cache.pool().now_wall(); + let mut dropped = RangeSet::new(); + for hole in super::miss::holes(self, groups) { + for groups in self.unfed(hole, now).0.iter() { + dropped.insert(groups.clone()); + } + } + for groups in dropped.iter() { + self.drop_groups(groups.clone(), err.clone()); + } + } +} + +/// The readers of one group of a fill, outliving its [`GroupFill`]. +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] +pub(crate) struct Readers { + fills: kio::Shared, + id: u64, + sequence: u64, + outcome: kio::Producer, +} + +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] +impl Readers { + /// Poll until no reader waits on the group. Once the fill ends its per-group demand goes + /// with it, so then until none of its readers is left. + pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + if self.outcome.poll_unused(waiter).is_ready() { + return Poll::Ready(()); + } + self.fills + .poll(waiter, |fills| match fills.pending.get(&self.id) { + Some(pending) if !pending.wants.contains_key(&self.sequence) => Poll::Ready(()), + _ => Poll::Pending, + }) + .map(drop) + } +} + +/// One group of a [`Fill`], for a session serving it a group at a time. Answering it answers +/// that group of the fill; dropping it unanswered leaves the group to the fill. +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] +pub(crate) struct GroupFill { + inner: Arc, + sequence: u64, + frame_start: u64, +} + +#[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] +impl GroupFill { + /// The group sequence to fetch. + pub(crate) fn sequence(&self) -> u64 { + self.sequence + } + + /// The delivery priority the readers asked for. + pub(crate) fn priority(&self) -> i8 { + self.inner.priority + } + + /// The widest budget among the readers, for a handler that forwards it. + pub(crate) fn max_delay(&self) -> Option { + self.inner.max_delay + } + + /// The first frame of the group the readers want; 0 is the whole group. + pub(crate) fn frame_start(&self) -> u64 { + self.frame_start + } + + /// Poll until no reader waits on this group, as [`Fill::poll_unwanted`]. + pub(crate) fn poll_unused(&self, waiter: &kio::Waiter) -> Poll<()> { + self.inner.poll_unwanted(self.sequence, waiter) + } + + /// Poll until the track closes, with its abort error. + pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { + TrackState::poll_closed(&self.inner.state, waiter) + } + + /// Whether a reader still waits on the fill. + #[cfg(test)] + pub(crate) fn is_used(&self) -> bool { + self.inner.result.is_used() + } + + /// The fill's answers, kept open as long as this handle lives. + #[cfg(test)] + pub(crate) fn joined(&self) -> kio::Producer { + self.inner.result.clone() + } + + /// The group's readers, for a session still writing it once accepted. Holds the fill's + /// answers open, as the group does not answer its readers until they take it. + pub(crate) fn readers(&self) -> Readers { + Readers { + fills: self.inner.fills.clone(), + id: self.inner.id, + sequence: self.sequence, + outcome: self.inner.result.clone(), + } + } + + /// The track the fill serves. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn track(&self) -> kio::Producer { + self.inner.state.clone() + } + + /// Insert the group into the cache, as [`Fill::accept`]. + pub(crate) fn accept(self, info: impl Into>) -> Result { + self.inner.accept(self.sequence, info.into()) + } + + /// Refuse the group with the source's answer, for the readers waiting now. Only a + /// datagram, or [`Error::Old`] under the publisher's max age (with every older group of + /// the fill), names it dropped. + pub(crate) fn reject(self, err: Error) { + self.inner.reject(self.sequence, err); + } + + /// Declare the track's exclusive final sequence, as the source answering this group + /// reported it. A no-op once the track declared an end, or holds a later group. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn finish_track_at(&self, final_sequence: u64) { + if let Ok(mut state) = TrackState::modify(&self.inner.state) + && state.end.is_none() + { + let _ = state.set_end(Position::group(final_sequence)); + } + } +} diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs new file mode 100644 index 00000000000..673c4ab8d20 --- /dev/null +++ b/rs/moq-net/src/model/track/miss.rs @@ -0,0 +1,610 @@ +//! A reader's misses: the sequences it reads that the cache doesn't hold and the live feed +//! will never deliver, asked of the track's fills until each arrives or is given up. +//! +//! Nothing above the newest group the feed produced is a miss, since it arrives live, so a +//! fill is bounded by construction. Below it, a hole is a miss once the feed is past it: +//! under where the feed starts, or accounted for by the arrival ledger without being cached +//! (evicted, or a gap the feed skipped). A hole the ledger still waits for is deferred and +//! judged again as the feed moves. +//! +//! Each sequence is judged once, so a group evicted after the reader judged it cached is +//! not a miss: judging it again would refill a group an arrival-order reader already took. +//! +//! A start is no promise, since a group below it may still arrive late. A hole under it is +//! asked at once, but a refusal is final only once the ledger gives up on it too; until +//! then the live feed may still deliver it. + +use std::{ + collections::BTreeSet, + ops::{self, RangeInclusive}, + task::Poll, + time::Duration, +}; + +use rangemap::{RangeInclusiveSet, RangeSet}; + +use super::fill::{Ask, FillState, Join}; +use super::{Drift, Order, Position, TrackState}; +use crate::Error; +use crate::model::subscription::Window; + +/// What one reader asks its fills with. +pub(super) struct Asking<'a> { + /// The reader's resolved ranges, for the frame each run's first group starts from. + pub window: &'a Window, + pub priority: i8, + pub max_delay: Option, + pub order: Order, +} + +/// What a probe judges against: the span read, the feed's newest group, where the feed +/// and readers start, and the drops revision. +type JudgedAt = (u64, u64, Option, Option, Option, Option, u64); + +/// The misses of one cursor, judged as what it reads or the feed moves. +#[derive(Default)] +pub(super) struct Misses { + /// What the last probe judged against. A probe that sees the same skips judging. + at: Option, + /// The sequences judged already, within what the reader reads up to the newest group. + judged: RangeSet, + /// Holes the live feed may still deliver, judged again on every probe. + deferred: RangeSet, + /// Asked under the feed's start and refused, never asked again: given up once the feed + /// gives up on them, unless they arrive first. + refused: RangeSet, + /// The fills this reader waits on. + joins: Vec, + /// The sequences of `joins` still unanswered. + wanted: RangeSet, + /// Answered by a fill and cached, not handed out yet. + filled: BTreeSet, + /// Never asked again: named dropped to the reader. + given_up: RangeSet, +} + +/// A run of misses the reader gives up, for the drop cursor to name. +pub(super) type GivenUp = Vec>; + +impl Misses { + /// The lowest sequence still to come from a fill: asked, or answered and not handed out. + pub fn lowest(&self) -> Option { + let wanted = self.wanted.iter().next().map(|run| run.start); + let filled = self.filled.first().copied(); + match (wanted, filled) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + } + } + + /// Whether a fill still owes this reader a group. + pub fn is_waiting(&self) -> bool { + !self.wanted.is_empty() + } + + /// The lowest sequence asked and not answered yet. + pub fn lowest_wanted(&self) -> Option { + self.wanted.iter().next().map(|run| run.start) + } + + /// Everything given up so far, for a drop cursor created after. + pub fn given_up(&self) -> &RangeSet { + &self.given_up + } + + /// The next answered group still cached in `order`, forgetting one evicted before it was + /// read. + pub fn next_filled( + &mut self, + state: &TrackState, + order: Order, + given_up: &mut GivenUp, + ) -> Option { + loop { + let sequence = match order { + Order::Ascending => self.filled.first(), + Order::Descending => self.filled.last(), + } + .copied()?; + if let Some(slot) = state.lookup.get(&sequence).filter(|slot| !slot.is_aborted()) { + return Some(slot.serving().clone()); + } + self.filled.remove(&sequence); + self.release(sequence); + self.give_up(sequence..sequence + 1, given_up); + } + } + + /// Take `sequence` as handed out, if a fill answered it. + pub fn take_filled(&mut self, sequence: u64) { + if self.filled.remove(&sequence) { + self.release(sequence); + } + } + + /// Stop holding a filled `sequence` in its fill: handed out, the group's own reader + /// keeps it wanted, or nothing does. + fn release(&mut self, sequence: u64) { + let mut lost = RangeSet::new(); + lost.insert(sequence..sequence + 1); + self.joins.retain_mut(|join| { + join.release(&lost); + !join.is_empty() + }); + } + + /// Forget what the reader no longer reads: below `from`, or outside `admitted`. Its fills + /// no longer owe it those groups, and one nobody waits on is withdrawn once its last join + /// goes. + pub fn retain(&mut self, from: u64, admitted: &RangeInclusiveSet) { + if self.wanted.is_empty() && self.filled.is_empty() && self.joins.is_empty() { + return; + } + let outside = |sequence: u64| sequence < from || !admitted.contains(&sequence); + self.filled.retain(|sequence| !outside(*sequence)); + let mut kept = RangeSet::new(); + for run in self.wanted.iter() { + for inside in clip(run.clone(), from, admitted) { + kept.insert(inside); + } + } + self.wanted = kept; + self.joins.retain_mut(|join| { + // By the join's whole span, not just what is unanswered: a filled group not + // taken yet is held too. + let inside: RangeSet = clip(join.groups.clone(), from, admitted).into_iter().collect(); + join.release(&inside.gaps(&join.groups).collect()); + !join.is_empty() + }); + } + + /// Take in what the joined fills answered, parking `waiter` on the rest. Returns the + /// runs to ask again: refused as too old for a budget tighter than this reader's. + pub fn resolve( + &mut self, + state: &kio::Consumer, + max_delay: Option, + given_up: &mut GivenUp, + waiter: &kio::Waiter, + ) -> RangeSet { + let mut retry = RangeSet::new(); + let mut i = 0; + while i < self.joins.len() { + let join = &self.joins[i]; + let wanted: RangeSet = self + .wanted + .overlapping(&join.groups) + .map(|run| run.start.max(join.groups.start)..run.end.min(join.groups.end)) + .collect(); + if wanted.is_empty() { + // Holding only filled groups until they are taken, it has nothing to answer. + match join.is_empty() { + true => drop(self.joins.swap_remove(i)), + false => i += 1, + } + continue; + } + let answers = match join.outcome().poll(waiter, |outcome| { + let answered = outcome.within(&wanted); + match answered.is_empty() { + true => Poll::Pending, + false => Poll::Ready(answered), + } + }) { + Poll::Ready(Ok(answers)) => answers, + // Ended without answering the rest: its handlers went away. Whatever the cache + // holds by now is served from it, and the rest is given up, as for an accept. + Poll::Ready(Err(closed)) => { + let mut answers = closed.within(&wanted); + let mut left = wanted.clone(); + for (groups, _) in &answers { + left.remove(groups.clone()); + } + answers.extend(left.iter().map(|run| (run.clone(), Ok(())))); + answers + } + Poll::Pending => { + i += 1; + continue; + } + }; + // Read only after the outcome's lock is released. + let state = state.read(); + // What is answered is no longer owed this reader, except a filled group: held until + // taken, so its source keeps writing it while the reader picks it up. + let mut taken = RangeSet::new(); + for (groups, answer) in answers { + self.wanted.remove(groups.clone()); + taken.insert(groups.clone()); + match answer { + // What the cache holds is filled, and the rest settled by run: a fill that + // ended unanswered may span far more groups than were ever cached. + Ok(()) => { + self.settle(&state, groups.clone(), given_up); + for (&sequence, slot) in state.lookup.range(groups) { + if !slot.is_aborted() { + self.filled.insert(sequence); + taken.remove(sequence..sequence + 1); + } + } + } + Err((Error::Old, asked)) if looser(max_delay, asked) => retry.insert(groups), + // A drop the track recorded is named by the drop cursor already. + Err(_) => self.settle(&state, groups, given_up), + } + } + drop(state); + self.joins[i].release(&taken); + // Judged again: the same join may have more to answer, or park the waiter now. + } + retry + } + + /// Judge the holes in `target` the last probe has not, returning the runs to ask a + /// fill for. Gives up a run already stale under this reader's budget. + pub fn judge( + &mut self, + state: &TrackState, + key: (u64, u64, Option), + target: impl FnOnce() -> RangeInclusiveSet, + drift: &Drift, + given_up: &mut GivenUp, + waiter: &kio::Waiter, + ) -> RangeSet { + let mut asks = RangeSet::new(); + // The feed's start is unknown until the serving session resolves it, and readers + // take nothing from a cache that does not reflect the feed. + if state.start_pending || !state.readable() { + return asks; + } + let Some(newest) = state.newest_produced else { + return asks; + }; + let at = ( + key.0, + key.1, + key.2, + Some(newest), + state.live_floor, + state.start_sequence, + state.drops_revision, + ); + if self.at == Some(at) && self.deferred.is_empty() { + return asks; + } + self.at = Some(at); + + let low = state.live_floor.unwrap_or(0); + // A run's exclusive end must exist, so the last sequence is never judged. + let top = newest.min(u64::MAX - 1); + let mut span = RangeSet::new(); + if low <= top { + for run in target().overlapping(&(low..=top)) { + span.insert((*run.start()).max(low)..(*run.end()).min(top) + 1); + } + } + // What left the span is forgotten, so it is judged afresh if it comes back, and a + // verdict behind the reader's floor stops costing it memory. + let clip = |set: &RangeSet| -> RangeSet { + set.iter() + .flat_map(|run| { + span.overlapping(run) + .map(move |s| s.start.max(run.start)..s.end.min(run.end)) + }) + .collect() + }; + let judged = clip(&self.judged); + self.refused = clip(&self.refused); + self.given_up = clip(&self.given_up); + let mut candidates: RangeSet = span.iter().flat_map(|run| judged.gaps(run)).collect(); + for run in std::mem::take(&mut self.deferred).iter() { + for s in span.overlapping(run) { + candidates.insert(s.start.max(run.start)..s.end.min(run.end)); + } + } + self.judged = judged; + for run in candidates.iter() { + self.judged.insert(run.clone()); + } + + // A refused group that arrived live is the feed's, and no longer refused. + let arrived: Vec = self + .refused + .iter() + .flat_map(|run| state.lookup.range(run.clone()).map(|(sequence, _)| *sequence)) + .collect(); + for sequence in arrived { + self.refused.remove(sequence..sequence + 1); + } + + let now = state.cache.pool().now_wall(); + let start = state.start_sequence.unwrap_or(0); + let mut wake: Option = None; + for run in candidates.iter() { + for hole in holes(state, run.clone()) { + for hole in self.unclaimed(state, hole) { + let (unfed, awaited) = state.unfed(hole, now); + for run in unfed.iter() { + asks.insert(run.clone()); + // Refused already: given up now the feed gave up too, not asked again. + for refused in self.refused.overlapping(run).cloned().collect::>() { + let gone = refused.start.max(run.start)..refused.end.min(run.end); + self.refused.remove(gone.clone()); + asks.remove(gone.clone()); + self.give_up(gone, given_up); + } + } + for (run, folds) in awaited { + // Under the feed's start: asked now rather than after the grace. + let early = run.start..run.end.min(start); + if !early.is_empty() { + asks.insert(early); + } + self.deferred.insert(run); + wake = match (wake, folds) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + } + } + } + } + for run in self.refused.iter() { + asks.remove(run.clone()); + } + for run in asks.iter() { + self.deferred.remove(run.clone()); + } + // A buffered datagram arrived, and is served as one. + for datagram in &state.datagrams { + asks.remove(datagram.sequence..datagram.sequence.saturating_add(1)); + } + // A gap the feed still waits for becomes a miss when it folds. + if let Some(at) = wake { + state.cache.watch_wall(at, waiter); + } + + // Starts never rewind, so a run whose newest group is stale under this reader's + // budget is stale throughout. + let stale: Vec> = asks + .iter() + .filter(|run| state.drifted(run.end - 1, &drift.edge, drift.budget)) + .cloned() + .collect(); + for run in stale { + asks.remove(run.clone()); + self.give_up(run, given_up); + } + asks + } + + /// Give up what a fill still owes this reader once it is stale under the reader's budget, + /// as the reader skips a stale cached group, parking `waiter` on each run going stale. + /// A fill answers on its handler's time, so an ordered reader would otherwise wait out + /// a stalled one for good. + pub fn expire(&mut self, state: &TrackState, drift: &Drift, given_up: &mut GivenUp, waiter: &kio::Waiter) { + let (mut stale, mut arrived) = (RangeSet::new(), RangeSet::new()); + for run in self.wanted.iter() { + // Each stretch between the groups cached inside the run shares one successor, so + // one check judges it whole. Groups arrive out of order, so a fresh late group + // keeps the stretch below it owed while the ones around it go stale. + let mut from = run.start; + // Only the live feed's arrivals split the run: an aborted group is a hole, and a + // backfill is a fill's answer, reaching the reader once that fill publishes it. + let cached = state + .lookup + .range(run.clone()) + .filter(|(_, slot)| slot.is_walked()) + .map(|(&sequence, _)| sequence); + for at in cached.chain(std::iter::once(run.end)) { + if from < at && state.poll_drifted(from, &drift.edge.servable, drift.budget, None, waiter) { + stale.insert(from..at); + } + // What arrived live is the feed's to serve, as the reader serves any cached group. + if at < run.end { + arrived.insert(at..at + 1); + } + from = at.saturating_add(1); + } + } + if stale.is_empty() && arrived.is_empty() { + return; + } + for run in stale.iter().chain(arrived.iter()) { + self.wanted.remove(run.clone()); + } + let mut released = stale.clone(); + for run in arrived.iter() { + released.insert(run.clone()); + } + self.joins.retain_mut(|join| { + join.release(&released); + !join.is_empty() + }); + for run in stale.iter() { + self.give_up(run.clone(), given_up); + } + } + + /// Ask the fills for `runs` in the reader's order, settling what no handler can take as + /// refused. Fills queue in the order asked, so a descending reader's newest run comes first. + pub fn ask( + &mut self, + state: &kio::Consumer, + fills: &kio::Shared, + runs: &RangeSet, + asking: &Asking<'_>, + given_up: &mut GivenUp, + ) { + // A group a range starts inside heads its own fill, since a fill reads every group + // after its first whole. + let mut cuts: Vec = asking.window.partial_starts().collect(); + cuts.sort_unstable(); + cuts.dedup(); + let mut runs: Vec> = runs + .iter() + .flat_map(|run| { + let inside = cuts.iter().copied().filter(|cut| run.start < *cut && *cut < run.end); + let starts = std::iter::once(run.start).chain(inside.clone()); + let ends = inside.chain(std::iter::once(run.end)); + starts.zip(ends).map(|(start, end)| start..end) + }) + .collect(); + if asking.order == Order::Descending { + runs.reverse(); + } + for run in runs { + let (frame, _) = asking.window.frames(run.start); + let ask = Ask { + start: Position { + group: run.start, + frame, + }, + end: run.end, + priority: asking.priority, + max_delay: asking.max_delay, + order: asking.order, + }; + let (joins, unfillable) = fills.lock().join(fills, &ask); + self.wanted.insert(run.clone()); + for groups in unfillable { + self.wanted.remove(groups.clone()); + self.settle(&state.read(), groups, given_up); + } + self.joins.extend(joins); + } + } + + /// The part of `hole` nobody has a verdict on yet. + fn unclaimed(&self, state: &TrackState, hole: ops::Range) -> Vec> { + let mut left = RangeSet::new(); + left.insert(hole.clone()); + // Only what overlaps the hole, so judging every run costs each one the same. + for (run, _) in state.drops.overlapping(&hole) { + left.remove(run.clone()); + } + for claimed in [&self.given_up, &self.wanted] { + for run in claimed.overlapping(&hole) { + left.remove(run.clone()); + } + } + for sequence in self.filled.range(hole) { + left.remove(*sequence..*sequence + 1); + } + left.iter().cloned().collect() + } + + /// Take in a fill's refusal of `groups`: final where the feed gave up on them too, + /// otherwise held until it does, since the live feed may still deliver them. + fn settle(&mut self, state: &TrackState, groups: ops::Range, given_up: &mut GivenUp) { + let now = state.cache.pool().now_wall(); + // What arrived meanwhile is the feed's to serve. + for hole in holes(state, groups) { + let mut left = RangeSet::new(); + left.insert(hole.clone()); + for run in state.unfed(hole, now).0.iter() { + left.remove(run.clone()); + self.give_up(run.clone(), given_up); + } + for run in left.iter() { + self.refused.insert(run.clone()); + self.deferred.insert(run.clone()); + } + } + } + + fn give_up(&mut self, groups: ops::Range, given_up: &mut GivenUp) { + if groups.is_empty() { + return; + } + self.given_up.insert(groups.clone()); + given_up.push(groups); + } +} + +impl TrackState { + /// Split `hole`, which the cache holds nothing for, into what the live feed will never + /// deliver and the gaps it still waits for, with when each stops being waited for. + /// + /// Never: accounted for by its arrivals (evicted, or a gap it skipped), or in a gap its + /// grace gave up on, or anything once the feed is over. A declared start is no promise: + /// a group below it may still arrive. + #[expect( + clippy::type_complexity, + reason = "the never-fed runs and the awaited gaps are returned together; naming them would only move the type" + )] + pub(super) fn unfed( + &self, + hole: ops::Range, + now: crate::time::Instant, + ) -> (RangeSet, Vec<(ops::Range, Option)>) { + let mut unfed = RangeSet::new(); + let mut awaited = Vec::new(); + // Nothing more arrives after an abort, a seal, or once the live edge reached a + // declared end with no tail owed: until then, a group below the end may still arrive. + if self.abort.is_some() || self.is_complete() { + unfed.insert(hole); + return (unfed, awaited); + } + let mut from = hole.start; + let grace = self.arrival_grace(); + let mut gap = + |groups: ops::Range, unfed: &mut RangeSet| match self.arrivals.folds_at(groups.start, grace) { + Some(at) if at <= now => unfed.insert(groups), + folds => awaited.push((groups, folds)), + }; + for arrived in self.arrivals.accounted(from..hole.end) { + if from < arrived.start { + gap(from..arrived.start, &mut unfed); + } + from = arrived.end; + unfed.insert(arrived); + } + if from < hole.end { + gap(from..hole.end, &mut unfed); + } + (unfed, awaited) + } +} + +/// The runs of `run` the cache holds nothing servable for. +pub(super) fn holes(state: &TrackState, run: ops::Range) -> Vec> { + let mut holes = Vec::new(); + let mut from = run.start; + for (&sequence, slot) in state.lookup.range(run.clone()) { + if slot.is_aborted() { + continue; + } + if from < sequence { + holes.push(from..sequence); + } + from = sequence + 1; + } + if from < run.end { + holes.push(from..run.end); + } + holes +} + +/// The parts of `run` at or above `from` inside `admitted`. +fn clip(run: ops::Range, from: u64, admitted: &RangeInclusiveSet) -> Vec> { + let run = run.start.max(from)..run.end; + if run.is_empty() { + return Vec::new(); + } + let inclusive: RangeInclusive = run.start..=run.end - 1; + admitted + .overlapping(&inclusive) + .map(|inside| (*inside.start()).max(run.start)..(*inside.end()).min(run.end - 1) + 1) + .collect() +} + +/// Whether `ours` tolerates more than the budget a fill `asked` with, so its [`Error::Old`] +/// was not this reader's. +fn looser(ours: Option, asked: Option) -> bool { + match (asked, ours) { + (Some(asked), Some(ours)) => ours > asked, + (Some(_), None) => true, + (None, _) => false, + } +} diff --git a/rs/moq-net/tests/integration/fetch_cancel.rs b/rs/moq-net/tests/integration/fetch_cancel.rs index 37cc7f447c3..1609c164f85 100644 --- a/rs/moq-net/tests/integration/fetch_cancel.rs +++ b/rs/moq-net/tests/integration/fetch_cancel.rs @@ -67,7 +67,7 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage }; let groups = request.dynamic(); let _track = request.accept(timed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; @@ -75,16 +75,12 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage match stage { Stage::Unanswered => { drop(waiting); - moq_clock::timeout( - Duration::from_secs(5), - kio::wait(|waiter| request.demand().poll_unused(waiter)), - ) - .await - .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")) - .expect("the pending fetch is still open"); + moq_clock::timeout(Duration::from_secs(5), kio::wait(|waiter| request.poll_unused(waiter))) + .await + .unwrap_or_else(|_| panic!("{ctx}: the publisher's fetch is still wanted")); } Stage::MidResponse => { - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"head")).unwrap(); let mut fetched = waiting.await.unwrap(); @@ -112,7 +108,7 @@ async fn abandoned_fetch_reaches_the_publisher(version: &str, relays: u64, stage assert_eq!(payloads, [&b"head"[..], &b"tail"[..]], "{ctx}"); } Stage::Drained => { - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"head")).unwrap(); group.write_frame(Timestamp::ZERO, Bytes::from_static(b"tail")).unwrap(); @@ -186,11 +182,11 @@ async fn a_refetch_within_the_linger_stays_on_the_relay(version: &str) { }; let groups = request.dynamic(); let _track = request.accept(timed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group .write_frame(Timestamp::ZERO, Bytes::from_static(b"segment")) .unwrap(); @@ -206,7 +202,7 @@ async fn a_refetch_within_the_linger_stays_on_the_relay(version: &str) { let mut refetch = Box::pin(consumer.track("video").unwrap().fetch_group(0, None)); let track_request = std::pin::pin!(dynamic.requested_track()); - let group_request = std::pin::pin!(groups.requested_group()); + let group_request = std::pin::pin!(groups.requested_fill()); let upstream = futures::future::select(track_request, group_request); let mut fetched = match futures::future::select(&mut refetch, upstream).await { futures::future::Either::Left((fetched, _)) => fetched.expect("the relay serves the group again"), diff --git a/rs/moq-net/tests/integration/range_anchors.rs b/rs/moq-net/tests/integration/range_anchors.rs index 7a4dcb2fea5..ee22231a19b 100644 --- a/rs/moq-net/tests/integration/range_anchors.rs +++ b/rs/moq-net/tests/integration/range_anchors.rs @@ -149,17 +149,15 @@ async fn each_wire_refuses_only_the_anchors_it_drops() { } } -/// Every wire carries the live and oldest starts, which it spells with no floor. +/// Every wire carries the live and oldest starts, which it spells with no floor. A wire whose +/// copy starts at the live edge fills the older groups after it. #[moq_clock::test] async fn every_wire_carries_the_live_and_oldest_starts() { for version in VERSIONS { let live = read(Some(version), Range::live()).await; assert_eq!(live.unwrap().last(), Some(&4), "{version}: live reads the latest group"); - let oldest = read(Some(version), Range::oldest()).await; - assert_eq!( - oldest.unwrap().last(), - Some(&4), - "{version}: oldest reads through the latest group" - ); + let mut oldest = read(Some(version), Range::oldest()).await.unwrap(); + oldest.sort_unstable(); + assert_eq!(oldest, [0, 1, 2, 3, 4], "{version}: oldest reads every group"); } } diff --git a/rs/moq-net/tests/integration/subscriber_max_age.rs b/rs/moq-net/tests/integration/subscriber_max_age.rs index 249d6a3d5c9..1b53354830f 100644 --- a/rs/moq-net/tests/integration/subscriber_max_age.rs +++ b/rs/moq-net/tests/integration/subscriber_max_age.rs @@ -19,7 +19,7 @@ async fn budget(track: &mut track::Producer, millis: u64, version: Version) { loop { if track .subscription() - .is_some_and(|subscription| subscription.max_delay == Duration::from_millis(millis)) + .is_some_and(|subscription| subscription.max_delay == Some(Duration::from_millis(millis))) { return; } diff --git a/rs/moq-net/tests/integration/untimed.rs b/rs/moq-net/tests/integration/untimed.rs index 8f933122a1c..f80e65755cd 100644 --- a/rs/moq-net/tests/integration/untimed.rs +++ b/rs/moq-net/tests/integration/untimed.rs @@ -250,11 +250,11 @@ async fn a_fetched_untimed_frame_arrives_untimed() { }; let groups = request.dynamic(); let _track = request.accept(untimed()); - let request = match futures::future::select(std::pin::pin!(groups.requested_group()), &mut waiting).await { + let request = match futures::future::select(std::pin::pin!(groups.requested_fill()), &mut waiting).await { futures::future::Either::Left((request, _)) => request.expect("the fetch reaches the publisher"), futures::future::Either::Right(_) => panic!("nothing answered the fetch"), }; - let mut group = request.accept(timed()).unwrap(); + let mut group = request.accept(0, timed()).unwrap(); group.write_frame(None, Bytes::from_static(b"segment")).unwrap(); group.finish().unwrap(); diff --git a/rs/moq-net/tests/loom.rs b/rs/moq-net/tests/loom.rs index a5a567ac5fd..cd20f3bd3ca 100644 --- a/rs/moq-net/tests/loom.rs +++ b/rs/moq-net/tests/loom.rs @@ -466,11 +466,11 @@ fn a_fetch_never_joins_a_withdrawn_attempt() { let consumer = track.consume(); let first = consumer.fetch_group(5, None); - let request = block_on(dynamic.requested_group()).expect("requested"); + let request = block_on(dynamic.requested_fill()).expect("requested"); let leaver = thread::spawn(move || { drop(first); - request.demand().is_used().then_some(request) + request.is_used().then_some(request) }); let mut retry = consumer.fetch_group(5, None); let request = match leaver.join().unwrap() { @@ -480,10 +480,10 @@ fn a_fetch_never_joins_a_withdrawn_attempt() { if let std::task::Poll::Ready(Err(err)) = polled { panic!("the retry joined a withdrawn attempt: {err}"); } - block_on(dynamic.requested_group()).expect("the retry queued a fresh request") + block_on(dynamic.requested_fill()).expect("the retry queued a fresh request") } }; - request.accept(None).expect("accept").finish().expect("finish"); + request.accept(5, None).expect("accept").finish().expect("finish"); block_on(retry).expect("the retry resolves"); }); } diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index 768d7312981..9312e029ed3 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -1466,7 +1466,7 @@ async fn subscriber_max_age_skips_bufferbloat(version: &str) { loop { if track .subscription() - .is_some_and(|subscription| subscription.max_delay == Duration::from_millis(100)) + .is_some_and(|subscription| subscription.max_delay == Some(Duration::from_millis(100))) { break; } diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index 11a6658c392..2feba4d52d5 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -1349,7 +1349,7 @@ mod tests { /// source, so the source publishes no group at all and serves this one /// through a [`moq_net::track::Dynamic`] instead. That handle also parks the /// fetch at exactly the point in question: past the rung's handler, before - /// its `GroupRequest::accept`. + /// its `Fill::accept`. #[tokio::test] async fn retirement_waits_for_an_unclaimed_fetch() { let mut source = source_catalog(320, 240); @@ -1396,9 +1396,9 @@ mod tests { let fetching = rung.fetch_group(7, None); // The rung's fetch task is now inside its source read, which is upstream of - // both its decoder and the `GroupRequest::accept` that claims output group 7. - let request = source_fetches.requested_group().await.expect("the source track closed"); - assert_eq!(request.sequence(), 7); + // both its decoder and the `Fill::accept` that claims output group 7. + let request = source_fetches.requested_fill().await.expect("the source track closed"); + assert_eq!(request.start().group, 7); // Retire the rung with the fetch parked there, and let the retirement land // before the source group exists. @@ -1414,7 +1414,7 @@ mod tests { // Release the fetch: it opens its decoder and only now claims output group // 7, which retirement had to leave writable. - let mut group = request.accept(None).unwrap(); + let mut group = request.accept(7, None).unwrap(); write_keyframe(&mut group); group.finish().unwrap(); @@ -1442,8 +1442,8 @@ mod tests { // something to transcode and resolves. let source_fetches = source._track.dynamic(); let server = tokio::spawn(async move { - while let Ok(request) = source_fetches.requested_group().await { - let mut group = request.accept(None).unwrap(); + while let Ok(request) = source_fetches.requested_fill().await { + let mut group = request.accept(request.start().group, None).unwrap(); write_keyframe(&mut group); group.finish().unwrap(); } @@ -1482,8 +1482,8 @@ mod tests { .fetch_group(7, moq_net::group::Fetch::default().with_frame_start(2)) .await; match partial { - Err(moq_net::Error::NotFound) => {} - Err(err) => panic!("expected a NotFound refusal, got {err}"), + Err(moq_net::Error::Unsupported) => {} + Err(err) => panic!("expected an Unsupported refusal, got {err}"), Ok(_) => panic!("served a fetch that starts mid-group"), } @@ -1535,32 +1535,31 @@ mod tests { rung.query().await.unwrap(); let fetching = rung.fetch_group(7, None); - let abandoned = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_group()) + let abandoned = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_fill()) .await .expect("the fetch never reached the source") .expect("the source track closed"); - assert_eq!(abandoned.sequence(), 7); + assert_eq!(abandoned.start().group, 7); // Leave before a single source frame exists. Encoding can only happen // if the handler keeps this fetch. drop(fetching); - tokio::time::timeout(std::time::Duration::from_secs(5), abandoned.demand().unused()) + tokio::time::timeout(std::time::Duration::from_secs(5), abandoned.unused()) .await - .expect("the abandoned fetch kept its source fetch") - .expect("the source fetch closed instead of going unused"); + .expect("the abandoned fetch kept its source fetch"); // The handler dropped the output request with the caller, so this is a // fresh request rather than a join of the one still encoding. let retry = rung.fetch_group(7, None); - let fresh = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_group()) + let fresh = tokio::time::timeout(std::time::Duration::from_secs(5), source_fetches.requested_fill()) .await .expect("the retry never fetched the source") .expect("the source track closed"); - assert_eq!(fresh.sequence(), 7); + assert_eq!(fresh.start().group, 7); drop(abandoned); let mut group = fresh - .accept(None) + .accept(7, None) .expect("the fresh request lost to the abandoned encode"); write_keyframe(&mut group); group.finish().unwrap(); diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 084edac57b9..de7d375bb24 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -8,7 +8,7 @@ //! group for group until the track goes `unused` again. The group open at //! that point is finished first, so a later reader of that sequence gets the //! whole group rather than an aborted one. -//! - A fetch of a specific group (`requested_group`) fetches that same group +//! - A fetch of a specific group (`requested_fill`) fetches that same group //! from the source and transcodes just that group with a fresh encoder. A //! fetch that starts mid-group is refused: a fresh encode's frames are only //! valid after the head that same encode produced. A fetch every caller @@ -441,9 +441,9 @@ async fn live(rung: &Rung, producer: &mut moq_net::track::Producer) -> Result permit.expect("the semaphore stays open"), }; - let request = tokio::select! { + let fill = tokio::select! { biased; () = retire.fired() => { retired = true; break; }, _ = finishing.wait_for(|finished| *finished) => break, - request = dynamic.requested_group() => match request { - Ok(request) => request, + fill = dynamic.requested_fill() => match fill { + Ok(fill) => fill, // The output track closed; nothing more to serve. Err(_) => break, }, }; - spawn_fetch(&mut tasks, rung.clone(), request, permit); + spawn_fill(&mut tasks, rung.clone(), fill, permit); } if retired { // A consumer that asked for a group before retirement is already waiting on - // it, whether or not this loop had reached it yet: the request queues on the + // it, whether or not this loop had reached it yet: the fill queues on the // dynamic handler the moment the fetch is made. Take what is queued now, so // dropping the handler below does not cancel a fetch that beat retirement. // @@ -505,14 +505,14 @@ async fn fetches( // handler admits a cache miss for as long as it is alive, so a drain that // waited for a slot with it still open would keep taking those in too and // the rung would never finish. Dropping it closes admission and releases - // whatever arrived after the pass; the requests already popped are held + // whatever arrived after the pass; the fills already popped are held // here, so they are unaffected. let mut queued = Vec::new(); loop { tokio::select! { biased; - request = dynamic.requested_group() => match request { - Ok(request) => queued.push(request), + fill = dynamic.requested_fill() => match fill { + Ok(fill) => queued.push(fill), // The output track closed; nothing left to serve. Err(_) => break, }, @@ -522,9 +522,9 @@ async fn fetches( } drop(dynamic); - for request in queued { + for fill in queued { let permit = limit.clone().acquire_owned().await.expect("the semaphore stays open"); - spawn_fetch(&mut tasks, rung.clone(), request, permit); + spawn_fill(&mut tasks, rung.clone(), fill, permit); } // Finish every group the handler accepted, or its producer stays open and @@ -544,66 +544,71 @@ async fn fetches( Ok(()) } -/// Spawn one fetch, holding its concurrency slot for the life of the task. -fn spawn_fetch( +/// Spawn one fill, holding its concurrency slot for the life of the task. +fn spawn_fill( tasks: &mut tokio::task::JoinSet<()>, rung: Rung, - request: moq_net::group::Request, + fill: moq_net::track::Fill, permit: tokio::sync::OwnedSemaphorePermit, ) { tasks.spawn(async move { let _permit = permit; - let sequence = request.sequence(); - if let Err(err) = fetch(rung, request).await { - tracing::warn!(%err, sequence, "transcode fetch failed"); + let mut last = None; + while fill.is_used() + && let Some(sequence) = fill.next() + && last != Some(sequence) + { + last = Some(sequence); + if let Err(err) = fetch(&rung, &fill, sequence).await { + tracing::warn!(%err, sequence, "transcode fetch failed"); + } } }); } -/// Transcode one specifically requested group, fetching it from the source. +/// Transcode group `sequence` of a fill, fetching it from the source. /// -/// A failure while someone still wants the group rejects the request: a source -/// fetch error passes through, and a pipeline or container error rejects with -/// [`moq_net::Error::Cancel`]. Dropping it would auto-reject with [`moq_net::Error::Dropped`], which -/// reads as "the handler vanished" and hides the actual failure. Once nobody -/// wants the group, the request is dropped instead: the last caller already -/// withdrew the attempt, and accepting afterwards would cache the group under a -/// fresh request for the same sequence, whose own accept is then -/// [`moq_net::Error::Duplicate`]. -async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error> { +/// The group is dropped only once the source says it no longer has it. Any other +/// failure while someone still wants the group refuses it for those readers alone, +/// so a later reader asks again. Once nobody wants the group, it is left unanswered +/// instead, releasing the permit for the groups the fill still owes: its last reader +/// already left it, and accepting afterwards would cache the group a fresh fill is +/// about to serve. +async fn fetch(rung: &Rung, fill: &moq_net::track::Fill, sequence: u64) -> Result<(), Error> { + let groups = sequence..sequence + 1; + let refuse = |err: moq_net::Error| fill.refuse_groups(groups.clone(), err); + let failed = || refuse(moq_net::Error::Stream(moq_net::StreamError::Internal)); // A fresh encode of this group need not match the bytes the reader's head // came from (the live encoder, or an earlier fetch), so its tail cannot // continue that head. Refuse it so the reader moves on to the next group - // boundary instead of decoding a splice. - if request.frame_start() != 0 { - tracing::debug!( - sequence = request.sequence(), - frame = request.frame_start(), - "refusing a fetch that starts mid-group" - ); - request.reject(moq_net::Error::NotFound); + // boundary instead of decoding a splice; a reader of the whole group still + // gets it. + let frame = fill.frame_start(sequence); + if frame != 0 { + tracing::debug!(sequence, frame, "refusing a fetch that starts mid-group"); + refuse(moq_net::Error::Unsupported); return Ok(()); } - // Watched until accept. After that the callers resolve from the cache, and - // this handle going unused is them picking the group up, not abandoning it. - let demand = request.demand(); - let track = match rung.broadcast.track(&rung.source) { Ok(track) => track, Err(err) => { - request.reject(err.clone()); + refuse(err.clone()); return Err(err.into()); } }; - let options = moq_net::group::Fetch::default().with_priority(request.priority()); + let options = moq_net::group::Fetch::default().with_priority(fill.priority()); let mut source = tokio::select! { biased; - _ = demand.unused() => return abandon(request), - source = track.fetch_group(request.sequence(), options) => match source { + _ = fill.unwanted(sequence) => return abandon(sequence), + source = track.fetch_group(sequence, options) => match source { Ok(source) => source, + Err(moq_net::Error::NotFound) => { + fill.drop_groups(groups); + return Err(moq_net::Error::NotFound.into()); + } Err(err) => { - request.reject(err.clone()); + refuse(err.clone()); return Err(err.into()); } }, @@ -618,7 +623,7 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error let pipeline = match rung.pipeline().await { Ok(pipeline) => pipeline, Err(err) => { - request.reject(moq_net::Error::Cancel); + failed(); return Err(err); } }; @@ -626,18 +631,18 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error let container = match rung.container() { Ok(container) => container, Err(err) => { - request.reject(moq_net::Error::Cancel); + failed(); return Err(err); } }; // Accepting is what publishes the group. One last look, so a caller who - // left while the pipeline was opening does not race a fresh request. - if !demand.is_used() { - return abandon(request); + // left while the pipeline was opening does not race a fresh fill. + if !fill.is_wanted(sequence) { + return abandon(sequence); } - let output = match request.accept(None) { + let output = match fill.accept(sequence, None) { Ok(output) => output, Err(err) => return Err(err.into()), }; @@ -649,14 +654,10 @@ async fn fetch(rung: Rung, request: moq_net::group::Request) -> Result<(), Error Ok(()) } -/// Drop a fetch nobody is waiting on. -/// -/// Dropping rejects with [`moq_net::Error::Dropped`]. No caller is left to see -/// it, and keeping the request would let this encode publish the group a fresh -/// request is about to serve. -fn abandon(request: moq_net::group::Request) -> Result<(), Error> { - tracing::debug!(sequence = request.sequence(), "dropping a fetch nobody wants"); - drop(request); +/// Leave a group nobody is waiting on: its fill no longer owes it, so this encode must not +/// publish the group a fresh fill is about to serve. +fn abandon(sequence: u64) -> Result<(), Error> { + tracing::debug!(sequence, "leaving a fetch nobody wants"); Ok(()) } @@ -953,6 +954,55 @@ mod tests { (rung, source, trigger) } + /// A group every reader narrowed past releases the fill's one-at-a-time fetch, so the + /// groups the fill still owes are fetched rather than waiting behind it. + #[tokio::test(start_paused = true)] + #[expect(clippy::disallowed_methods, reason = "tokio's paused clock bounds the waits")] + async fn a_group_nobody_reads_releases_its_fill() { + use std::task::Poll; + let (rung, source, _retire) = idle_rung(); + let source_fills = source.create_track("video", None).unwrap().dynamic(); + + let output = moq_net::broadcast::Info::default().produce(); + let timed = moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI); + let mut track = output.create_track("video/120p", timed).unwrap(); + let fills = track.dynamic(); + let ascending = |groups| { + moq_net::track::Subscription::default() + .with_max_delay(None) + .with_range(groups) + .with_order(moq_net::track::Order::Ascending) + }; + let mut reader = track.subscribe(ascending(7..)); + track.start_at(10).unwrap(); + let mut live = track.create_group(moq_net::group::Info { sequence: 10 }).unwrap(); + live.write_frame(moq_net::Timestamp::from_millis(10).unwrap(), &b"x"[..]) + .unwrap(); + live.finish().unwrap(); + let waiter = kio::Waiter::noop(); + while let Poll::Ready(Ok(Some(_))) = reader.poll_recv_group(&waiter) {} + let fill = fills.requested_fill().await.unwrap(); + assert_eq!((fill.start().group, fill.end()), (7, 10)); + + let mut tasks = tokio::task::JoinSet::new(); + let permit = Arc::new(Semaphore::new(1)).try_acquire_owned().unwrap(); + spawn_fill(&mut tasks, rung, fill, permit); + let wait = std::time::Duration::from_secs(5); + let first = tokio::time::timeout(wait, source_fills.requested_fill()) + .await + .expect("group 7 is fetched from the source") + .unwrap(); + assert_eq!(first.start().group, 7); + + reader.update(ascending(8..)).unwrap(); + assert!(reader.poll_recv_group(&waiter).is_pending()); + let next = tokio::time::timeout(wait, source_fills.requested_fill()) + .await + .expect("the fill waited behind group 7, which nobody reads") + .unwrap(); + assert_eq!(next.start().group, 8); + } + /// A restarted source cancels the serving future even while a rung waits for media. /// Its subscribers must see that cause, rather than an unfinished producer drop. #[tokio::test(start_paused = true)] diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index 44d43038daf..24f7a656290 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -737,7 +737,8 @@ mod tests { let subscription = published.subscription().expect("the consumer subscribed"); assert_eq!( - subscription.max_delay, max_delay, + subscription.max_delay, + Some(max_delay), "the age budget did not reach the publisher" ); @@ -1200,7 +1201,7 @@ mod tests { ) .await; assert_eq!( - published.subscription().map(|sub| sub.max_delay), + published.subscription().and_then(|sub| sub.max_delay), Some(WARMUP), "the two subscriptions did not share one request at the larger budget" ); @@ -1357,7 +1358,7 @@ mod tests { .await .unwrap(); assert!(matches!(consumer.lanes[Lane::Max as usize], Slot::Closed)); - assert_eq!(track.subscription().map(|sub| sub.max_delay), Some(max_delay)); + assert_eq!(track.subscription().and_then(|sub| sub.max_delay), Some(max_delay)); } } diff --git a/swift/Sources/Moq/Dynamic.swift b/swift/Sources/Moq/Dynamic.swift index 7767071f1cc..f31621b7536 100644 --- a/swift/Sources/Moq/Dynamic.swift +++ b/swift/Sources/Moq/Dynamic.swift @@ -37,39 +37,92 @@ public final class TrackRequest: Sendable { } } -/// A request to produce one uncached group for a fetch consumer. -public final class GroupRequest: Sendable { - let ffi: MoqGroupRequest - - init(_ ffi: MoqGroupRequest) { +/// A run of groups readers miss, for a `TrackDynamic` to serve. +/// +/// Accept each group you have and drop the rest; `next()` names what is still owed, in the +/// fill's order. Releasing the fill names whatever is left as dropped while a reader still +/// wants it; once nobody does, it is withdrawn, so stop serving it when `unused()` returns. +/// Stop serving a single group when `unwanted(sequence:)` returns for it, and move on to `next()`. +public final class Fill: Sendable { + let ffi: MoqFill + + init(_ ffi: MoqFill) { self.ffi = ffi } - /// The requested group sequence within the track. - public var sequence: UInt64 { - ffi.sequence() + /// The first group to fill. + public var startGroup: UInt64 { + ffi.startGroup() + } + + /// The frame within the first group to start from. + public var startFrame: UInt64 { + ffi.startFrame() } - /// The consumer's delivery priority for this fetch. + /// The exclusive end group. + public var endGroup: UInt64 { + ffi.endGroup() + } + + /// The most urgent delivery priority among the readers waiting on it. public var priority: Int8 { ffi.priority() } - /// Accept the request and return a producer for the group. - public func accept() throws -> GroupProducer { - GroupProducer(try ffi.accept()) + /// The most tolerant age budget among its readers in microseconds, or nil for the + /// publisher's max age alone. + public var maxDelayUs: UInt64? { + ffi.maxDelayUs() } - /// Reject the fetch with an application error code. - public func abort(errorCode: UInt16) throws { - try ffi.abort(errorCode: errorCode) + /// Which end to fill first. + public var order: Subscription.Order { + ffi.order() + } + + /// The next group still owed in the fill's order, or nil once every group is answered. + public func next() -> UInt64? { + ffi.next() + } + + /// Insert group `sequence` and return a producer for it. The first group starts at + /// `startFrame`, so its frames keep their indices. + public func accept(sequence: UInt64) throws -> GroupProducer { + GroupProducer(try ffi.accept(sequence: sequence)) + } + + /// Name groups `start` up to the exclusive `end` as never to be delivered. + public func dropGroups(start: UInt64, end: UInt64) { + ffi.dropGroups(start: start, end: end) + } + + /// Whether a reader still waits on this fill. + public var isUsed: Bool { + ffi.isUsed() + } + + /// Suspend until no reader waits on this fill. + public func unused() async throws { + try await ffi.unused() + } + + /// Whether a reader still waits for group `sequence` from this fill: false once every + /// reader took it or narrowed past it, or nobody waits on the fill. + public func isWanted(sequence: UInt64) -> Bool { + ffi.isWanted(sequence: sequence) + } + + /// Suspend until no reader waits for group `sequence` from this fill, so stop serving it. + public func unwanted(sequence: UInt64) async throws { + try await ffi.unwanted(sequence: sequence) } } -/// A stream of uncached group requests for one track. +/// A stream of the fills readers of one track need: runs of groups the cache lacks. public final class TrackDynamic: AsyncSequence, Sendable { - /// The group request emitted by this sequence. - public typealias Element = GroupRequest + /// The fill emitted by this sequence. + public typealias Element = Fill let ffi: MoqTrackDynamic @@ -77,20 +130,20 @@ public final class TrackDynamic: AsyncSequence, Sendable { self.ffi = ffi } - /// Wait for the next uncached group request. - public func requestedGroup() async throws -> GroupRequest { - GroupRequest(try await ffi.requestedGroup()) + /// Wait for the next fill. + public func requestedFill() async throws -> Fill { + Fill(try await ffi.requestedFill()) } - /// Cancel all current and future group request waits. + /// Cancel all current and future fill waits. public func cancel() { ffi.cancel() } /// Create an iterator that cancels native waits when iteration ends. - public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { + public func makeAsyncIterator() -> AsyncThrowingStream.Iterator { moqStream(cancel: { [ffi] in ffi.cancel() }) { [ffi] in - GroupRequest(try await ffi.requestedGroup()) + Fill(try await ffi.requestedFill()) }.makeAsyncIterator() } } diff --git a/test/drill/mutations/subscriber-max-age-ignored.patch b/test/drill/mutations/subscriber-max-age-ignored.patch index 5e25216f588..58f45711bc8 100644 --- a/test/drill/mutations/subscriber-max-age-ignored.patch +++ b/test/drill/mutations/subscriber-max-age-ignored.patch @@ -4,12 +4,12 @@ # Preserve negotiation and decoding, but ignore the requested budget when serving. --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs -@@ -1728,9 +1728,6 @@ +@@ -1741,9 +1741,6 @@ // A live SUBSCRIBE's NEW_GROUP_REQUEST asks nothing more: subscribing is itself a join, // which the track's refresh ratio judges. let mut subscription = serving_subscription(msg.subscriber_priority).with_order(msg.group_order.to_order()); - if let Some(max_age) = msg.max_age { -- subscription.max_delay = Duration::from_millis(max_age); +- subscription.max_delay = Some(Duration::from_millis(max_age)); - } // A range that resolves without the live edge has a fixed start, which is no join: // bound the subscription before subscribing so the ratio never judges it. From the