lightstreamer_rs/client/updates.rs
1//! What one subscription tells you, and the stream that delivers it.
2
3use std::pin::Pin;
4use std::task::{Context, Poll};
5
6use futures_util::Stream;
7use tokio::sync::mpsc;
8
9use crate::client::events::SubscriptionId;
10use crate::client::router::RouterCommand;
11use crate::error::ServerError;
12use crate::session::{
13 CommandFields as WireCommandFields, FilteringMode, MaxFrequency as WireFrequency,
14 SubscriptionEvent as Wire,
15};
16use crate::subscription::item_update::ItemUpdate;
17
18/// Where the key and the command sit in a `COMMAND`-mode field schema
19/// [`docs/spec/04-notifications.md` §3.2].
20///
21/// Both positions are **1-based**, matching the numbering
22/// [`ItemUpdate`] uses everywhere.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24#[non_exhaustive]
25pub struct CommandFields {
26 /// Position of the field that identifies the row.
27 pub key: usize,
28 /// Position of the field that says what happened to the row — `ADD`,
29 /// `UPDATE` or `DELETE`.
30 pub command: usize,
31}
32
33#[cfg(feature = "test-util")]
34impl CommandFields {
35 /// Assembles one field by field, for [`crate::test_util`].
36 #[must_use]
37 pub(crate) const fn from_parts(key: usize, command: usize) -> Self {
38 Self { key, command }
39 }
40}
41
42impl From<WireCommandFields> for CommandFields {
43 fn from(fields: WireCommandFields) -> Self {
44 Self {
45 key: fields.key,
46 command: fields.command,
47 }
48 }
49}
50
51/// The update rate the server has settled on for a subscription
52/// [`docs/spec/04-notifications.md` §3.8].
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
54#[non_exhaustive]
55pub enum UpdateFrequency {
56 /// No limit is applied.
57 Unlimited,
58 /// At most this many updates per second, per item.
59 Limited {
60 /// The limit exactly as the server wrote it. Kept as text because the
61 /// protocol carries it as text and this crate never reinterprets a
62 /// server-supplied number on the caller's behalf.
63 updates_per_second: String,
64 },
65 /// **The rate could not be read**, so do not treat it as a limit.
66 ///
67 /// The server sent something that is neither `unlimited` nor a decimal
68 /// number [`docs/spec/04-notifications.md` §3.8]. It is reported rather
69 /// than dropped, and reported apart from
70 /// [`Limited`](UpdateFrequency::Limited) rather than inside it, because a
71 /// caller that reads `Limited { updates_per_second: "" }` and divides by it
72 /// is acting on a number nobody sent. Log it, or treat the subscription's
73 /// rate as unknown — but do not compute with it.
74 Unrecognized {
75 /// The value as it arrived.
76 literal: String,
77 },
78}
79
80impl From<WireFrequency> for UpdateFrequency {
81 fn from(frequency: WireFrequency) -> Self {
82 match frequency {
83 WireFrequency::Unlimited => Self::Unlimited,
84 WireFrequency::Limited { updates_per_second } => Self::Limited { updates_per_second },
85 WireFrequency::Unrecognized { literal } => Self::Unrecognized { literal },
86 }
87 }
88}
89
90/// Whether the server may drop or merge updates for a subscription to keep up
91/// [`docs/spec/04-notifications.md` §3.8].
92#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
93#[non_exhaustive]
94pub enum Filtering {
95 /// The server may filter: it merges or discards updates rather than fall
96 /// behind, and you see fewer events than the adapter produced.
97 Filtered,
98 /// The server may not filter: every update is delivered. If it cannot keep
99 /// up it says so with a [`SubscriptionEvent::Overflow`] rather than
100 /// dropping silently.
101 Unfiltered,
102 /// The server named a mode this version does not know.
103 ///
104 /// Preserved rather than rejected, so a newer server cannot break an older
105 /// client over one word of an otherwise well-formed line.
106 Unrecognized {
107 /// The word as it arrived.
108 literal: String,
109 },
110}
111
112impl From<FilteringMode> for Filtering {
113 fn from(mode: FilteringMode) -> Self {
114 match mode {
115 FilteringMode::Filtered => Self::Filtered,
116 FilteringMode::Unfiltered => Self::Unfiltered,
117 FilteringMode::Unrecognized { literal } => Self::Unrecognized { literal },
118 }
119 }
120}
121
122/// Everything one subscription can tell you.
123///
124/// Delivered by [`Updates`]. `#[non_exhaustive]`, so match with a trailing `_`
125/// arm and a later version adding a variant will not break your build.
126///
127/// The ordinary shape of a subscription's life is
128/// [`Activated`](SubscriptionEvent::Activated), then a run of
129/// [`Update`](SubscriptionEvent::Update)s — snapshot first if one was asked
130/// for, real-time after — and finally either
131/// [`Unsubscribed`](SubscriptionEvent::Unsubscribed) or
132/// [`Rejected`](SubscriptionEvent::Rejected). After a session is replaced, a
133/// second `Activated` arrives and the run starts over.
134#[derive(Debug, Clone, PartialEq, Eq)]
135#[non_exhaustive]
136pub enum SubscriptionEvent {
137 /// The subscription is live on the server and its shape is now known
138 /// [`docs/spec/04-notifications.md` §3.1, §3.2].
139 ///
140 /// Arrives again whenever the subscription is re-created on a session that
141 /// replaced a lost one, in which case every item's state has been rebuilt
142 /// from nothing.
143 Activated {
144 /// How many items the server resolved the item group into.
145 item_count: usize,
146 /// How many fields the server resolved the field schema into. Every
147 /// update carries exactly this many values.
148 field_count: usize,
149 /// Present exactly for [`SubscriptionMode::Command`](crate::SubscriptionMode)
150 /// subscriptions, which the server activates differently because it
151 /// must also say where the key and command fields are.
152 command_fields: Option<CommandFields>,
153 },
154
155 /// New values for one item [`docs/spec/04-notifications.md` §2].
156 ///
157 /// The update carries the item's **complete** state, not only what
158 /// changed, so every field is readable; ask
159 /// [`ItemUpdate::changed_fields`] what actually moved.
160 Update(Box<ItemUpdate>),
161
162 /// The snapshot of one item is complete; everything after this is a live
163 /// change [`docs/spec/04-notifications.md` §3.5].
164 ///
165 /// Sent in `DISTINCT` and `COMMAND` modes. A `MERGE` snapshot is a single
166 /// update and gets no boundary marker; `RAW` has no snapshot at all.
167 EndOfSnapshot {
168 /// **1-based** index of the item whose snapshot ended.
169 item_index: usize,
170 },
171
172 /// The server cleared one item's snapshot
173 /// [`docs/spec/04-notifications.md` §3.6].
174 ///
175 /// Drop the list, history or row set you accumulated for this item. The
176 /// field values this crate tracks are deliberately **not** cleared, so
177 /// that a delta arriving straight afterwards still decodes.
178 SnapshotCleared {
179 /// **1-based** index of the item whose snapshot was cleared.
180 item_index: usize,
181 },
182
183 /// The **server** dropped updates for one item, because of its own buffer
184 /// limits [`docs/spec/04-notifications.md` §3.7].
185 ///
186 /// Those updates are gone; nothing can reconstruct them, and the item's
187 /// values now reflect only what survived. This is the protocol's own way
188 /// of surfacing loss rather than hiding it, and this crate passes it
189 /// straight through.
190 ///
191 /// It can only happen in `RAW` mode, for `ADD`/`DELETE` events in
192 /// `COMMAND` mode, and for subscriptions that asked for
193 /// [`MaxFrequency::Unfiltered`](crate::MaxFrequency).
194 Overflow {
195 /// **1-based** index of the item whose updates were dropped.
196 item_index: usize,
197 /// How many update events were dropped.
198 dropped_count: u64,
199 },
200
201 /// The subscription's frequency configuration
202 /// [`docs/spec/04-notifications.md` §3.8].
203 ///
204 /// Arrives once when the subscription starts — possibly before
205 /// [`Activated`](SubscriptionEvent::Activated) — and again after every
206 /// reconfiguration, even one that changed nothing.
207 Reconfigured {
208 /// The rate now in force.
209 max_frequency: UpdateFrequency,
210 /// Whether the server may filter.
211 filtering: Filtering,
212 },
213
214 /// The subscription request could not be sent, and will be retried when
215 /// the session next binds.
216 ///
217 /// **Not terminal, and not a refusal.** The server never saw the request —
218 /// the connection was gone, or the transport rejected it — so it has no
219 /// opinion on your subscription yet. This crate keeps the subscription in
220 /// its desired set and re-issues it on the next connection, after which
221 /// [`Activated`](SubscriptionEvent::Activated) arrives as usual.
222 ///
223 /// You do not have to do anything. It is surfaced because it means a real
224 /// delay before data starts, and a subscription that seems slow to start
225 /// should not be a mystery.
226 Deferred {
227 /// What stopped it. Diagnostic text; never a credential.
228 reason: String,
229 },
230
231 /// The server refused the subscription, with a code from **Appendix B**
232 /// [`docs/spec/05-error-codes.md` §2].
233 ///
234 /// Terminal: nothing else arrives on this stream, and the subscription is
235 /// not retried on a later session. Common causes are a bad item group
236 /// (code 21), a bad field schema (23), a mode the item does not allow
237 /// (24), and — in `COMMAND` mode — a schema with no key or no command
238 /// field (15, 16).
239 ///
240 /// Contrast [`Deferred`](SubscriptionEvent::Deferred), where the request
241 /// never reached the server and is still coming.
242 Rejected(ServerError),
243
244 /// The subscription ended [`docs/spec/04-notifications.md` §3.4].
245 ///
246 /// Terminal: no further update for these items and fields will be sent.
247 Unsubscribed,
248
249 /// A notification for this subscription could not be interpreted.
250 ///
251 /// Never fatal and never silent: the stream keeps running, and the item
252 /// state it refers to is whatever the last decodable update left. Seeing
253 /// this repeatedly means this client and the server disagree about the
254 /// subscription's shape, which is worth reporting as a bug.
255 Undecodable {
256 /// What went wrong, in terms a bug report can use.
257 detail: String,
258 },
259}
260
261impl SubscriptionEvent {
262 /// Whether nothing further will arrive on this subscription's stream.
263 #[must_use]
264 #[inline]
265 pub const fn is_terminal(&self) -> bool {
266 matches!(self, Self::Unsubscribed | Self::Rejected(_))
267 }
268
269 /// Translates the subscription layer's event into the public one.
270 pub(crate) fn from_wire(event: Wire) -> Self {
271 match event {
272 Wire::Activated {
273 item_count,
274 field_count,
275 command_fields,
276 } => Self::Activated {
277 item_count,
278 field_count,
279 command_fields: command_fields.map(Into::into),
280 },
281 Wire::Update(update) => Self::Update(Box::new(update)),
282 Wire::EndOfSnapshot { item_index } => Self::EndOfSnapshot { item_index },
283 Wire::SnapshotCleared { item_index } => Self::SnapshotCleared { item_index },
284 Wire::Overflow {
285 item_index,
286 dropped_count,
287 } => Self::Overflow {
288 item_index,
289 dropped_count,
290 },
291 Wire::Reconfigured {
292 max_frequency,
293 filtering,
294 } => Self::Reconfigured {
295 max_frequency: max_frequency.into(),
296 filtering: filtering.into(),
297 },
298 Wire::Unsubscribed => Self::Unsubscribed,
299 }
300 }
301}
302
303/// The stream of one subscription's events.
304///
305/// Returned by [`Client::subscribe`](crate::Client::subscribe). It implements
306/// [`futures_util::Stream`], so the usual combinators work and the plain shape
307/// is a `while let` loop:
308///
309/// ```no_run
310/// use futures_util::StreamExt;
311/// use lightstreamer_rs::SubscriptionEvent;
312///
313/// # async fn run(mut updates: lightstreamer_rs::Updates) {
314/// while let Some(event) = updates.next().await {
315/// if let SubscriptionEvent::Update(update) = event {
316/// println!("{}: {:?}", update.item_name(), update.changed_fields());
317/// }
318/// }
319/// # }
320/// ```
321///
322/// # Dropping this stream unsubscribes
323///
324/// There is no deregistration dance: drop the `Updates` and the client sends
325/// the unsubscription for you. The request goes out asynchronously — the drop
326/// itself does not block and cannot fail — so a `UNSUB` may still be in flight
327/// when the value is gone. If you need to observe the unsubscription
328/// completing, call [`Client::unsubscribe`](crate::Client::unsubscribe)
329/// instead and keep reading until [`SubscriptionEvent::Unsubscribed`].
330///
331/// # Backpressure: bounded, and nothing is dropped
332///
333/// The stream is fed by a **bounded** channel whose capacity is
334/// [`ConnectionOptions::with_update_capacity`](crate::ConnectionOptions::with_update_capacity)
335/// — 1024 events by default. When it fills, the client **blocks** rather than
336/// discarding anything, and that backpressure propagates through the session
337/// down to the socket. Concretely, a consumer that stops polling this stream
338/// will, once the buffer fills, stall **the whole client**: other
339/// subscriptions and the session's own liveness included.
340///
341/// That is a deliberate choice, and the alternatives are worse. Dropping
342/// updates locally would corrupt the notification count that makes recovery
343/// after a broken connection correct
344/// [`docs/spec/02-session-lifecycle.md` §5.2], and it would hide loss the
345/// protocol goes out of its way to report — TLCP has its own overflow
346/// notification precisely so that a client is *told* when data was discarded
347/// [`docs/spec/04-notifications.md` §3.7].
348///
349/// # What a long stall actually does
350///
351/// "Stalls the client" is literal, and it has a consequence worth planning
352/// for. While the client is blocked on a full stream it is not reading from
353/// the socket either, so its **liveness timers do not advance**. If you stop
354/// consuming for longer than the keepalive interval the server negotiated —
355/// reported to you as [`Connected::keepalive`](crate::Connected::keepalive),
356/// plus [`ConnectionOptions::with_keepalive_slack`](crate::ConnectionOptions::with_keepalive_slack)
357/// — then the moment you start reading again the client will conclude the
358/// connection went silent, tear it down, and recover
359/// [`docs/spec/02-session-lifecycle.md` §8.1]. You will see a
360/// [`SessionEvent::Disconnected`](crate::SessionEvent::Disconnected) carrying
361/// [`DisconnectReason::Stalled`](crate::DisconnectReason::Stalled), followed
362/// by a reconnection.
363///
364/// Nothing is lost when that happens — recovery is what the notification count
365/// exists for — but it is real work, and it is avoidable. A consumer that may
366/// pause for seconds at a time should either buffer on its own side or, better,
367/// ask the server to send less.
368///
369/// If you cannot keep up, say so to the server, which is where the protocol
370/// puts that decision: cap the rate with
371/// [`Subscription::with_max_frequency`](crate::Subscription::with_max_frequency),
372/// or bound what the server buffers with
373/// [`Subscription::with_buffer_size`](crate::Subscription::with_buffer_size).
374/// The server will then merge or drop on its side, and tell you it did with a
375/// [`SubscriptionEvent::Overflow`].
376///
377/// # The one exception: shutdown
378///
379/// "Nothing is dropped" holds **while the client is running**. It does not
380/// survive an ordered shutdown, and it deliberately does not:
381/// [`Client::disconnect`](crate::Client::disconnect) and dropping the
382/// [`Client`](crate::Client) are signalled out of band, every blocked delivery
383/// gives way to them, and whatever had not yet been delivered is discarded
384/// along with this stream.
385///
386/// Without that exception a consumer that walked away could make the client
387/// impossible to stop — no disconnect, no drop, and a task and a socket alive
388/// until the process ended. Nothing observable is lost by it: a caller still
389/// reading frees room and the delivery completes, and a caller that has asked
390/// to disconnect has said it wants no more. See
391/// `docs/adr/0003-typed-event-stream-as-delivery-surface.md`.
392#[derive(Debug)]
393pub struct Updates {
394 id: SubscriptionId,
395 events: mpsc::Receiver<SubscriptionEvent>,
396 /// Unbounded on purpose: [`Drop`] cannot await, and this channel carries
397 /// one small message per subscribe and unsubscribe — user actions, not
398 /// data volume — so it cannot grow with the update flow.
399 control: mpsc::UnboundedSender<RouterCommand>,
400}
401
402impl Updates {
403 /// Wraps the receiving half the router just registered.
404 pub(crate) const fn new(
405 id: SubscriptionId,
406 events: mpsc::Receiver<SubscriptionEvent>,
407 control: mpsc::UnboundedSender<RouterCommand>,
408 ) -> Self {
409 Self {
410 id,
411 events,
412 control,
413 }
414 }
415
416 /// The handle identifying this subscription.
417 ///
418 /// Stable for the life of the stream, including across a session that was
419 /// lost and replaced. Use it to correlate with
420 /// [`SessionEvent::Resubscribed`](crate::SessionEvent::Resubscribed).
421 #[must_use]
422 #[inline]
423 pub const fn id(&self) -> SubscriptionId {
424 self.id
425 }
426}
427
428impl Stream for Updates {
429 type Item = SubscriptionEvent;
430
431 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
432 self.events.poll_recv(cx)
433 }
434}
435
436impl Drop for Updates {
437 /// Unsubscribes.
438 ///
439 /// Best effort by necessity: a `Drop` cannot await and cannot report a
440 /// failure. The command channel is unbounded, so the send only fails when
441 /// the client has already stopped — in which case the subscription is gone
442 /// anyway.
443 fn drop(&mut self) {
444 if self
445 .control
446 .send(RouterCommand::StreamDropped { id: self.id })
447 .is_err()
448 {
449 tracing::debug!(
450 id = self.id.get(),
451 "subscription stream dropped after the client"
452 );
453 }
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
462 fn test_events_that_end_a_subscription_are_marked_terminal() {
463 assert!(SubscriptionEvent::Unsubscribed.is_terminal());
464 assert!(SubscriptionEvent::Rejected(ServerError::new(19, "not found")).is_terminal());
465 assert!(
466 !SubscriptionEvent::EndOfSnapshot { item_index: 1 }.is_terminal(),
467 "end of snapshot is a boundary, not an ending"
468 );
469 assert!(
470 !SubscriptionEvent::Overflow {
471 item_index: 1,
472 dropped_count: 4
473 }
474 .is_terminal()
475 );
476 }
477
478 #[test]
479 fn test_wire_events_translate_without_losing_anything() {
480 let activated = SubscriptionEvent::from_wire(Wire::Activated {
481 item_count: 3,
482 field_count: 4,
483 command_fields: Some(WireCommandFields { key: 1, command: 2 }),
484 });
485 assert!(matches!(
486 activated,
487 SubscriptionEvent::Activated {
488 item_count: 3,
489 field_count: 4,
490 command_fields: Some(CommandFields { key: 1, command: 2 })
491 }
492 ));
493
494 let overflow = SubscriptionEvent::from_wire(Wire::Overflow {
495 item_index: 2,
496 dropped_count: 17,
497 });
498 assert!(matches!(
499 overflow,
500 SubscriptionEvent::Overflow {
501 item_index: 2,
502 dropped_count: 17
503 }
504 ));
505 }
506
507 #[test]
508 fn test_reconfiguration_keeps_the_servers_own_text() {
509 let event = SubscriptionEvent::from_wire(Wire::Reconfigured {
510 max_frequency: WireFrequency::Limited {
511 updates_per_second: "3.0".to_owned(),
512 },
513 filtering: FilteringMode::Unrecognized {
514 literal: "novel".to_owned(),
515 },
516 });
517 assert!(matches!(
518 event,
519 SubscriptionEvent::Reconfigured {
520 max_frequency: UpdateFrequency::Limited { updates_per_second },
521 filtering: Filtering::Unrecognized { literal },
522 } if updates_per_second == "3.0" && literal == "novel"
523 ));
524 }
525
526 #[test]
527 fn test_an_unreadable_frequency_is_not_delivered_as_a_limit() {
528 // A `CONF` whose max-frequency argument is neither `unlimited` nor a
529 // decimal number [`docs/spec/04-notifications.md` §3.8] must not reach
530 // the caller inside `Limited`, where it would look like a rate.
531 let event = SubscriptionEvent::from_wire(Wire::Reconfigured {
532 max_frequency: WireFrequency::Unrecognized {
533 literal: "banana".to_owned(),
534 },
535 filtering: FilteringMode::Filtered,
536 });
537 assert!(matches!(
538 event,
539 SubscriptionEvent::Reconfigured {
540 max_frequency: UpdateFrequency::Unrecognized { literal },
541 filtering: Filtering::Filtered,
542 } if literal == "banana"
543 ));
544 }
545}