Skip to main content

cortexkit_bus_inmemory/
conformance.rs

1use std::time::Duration;
2
3use async_trait::async_trait;
4use cortexkit_bus_trait::{
5    map_connection_failure, map_credential_sign_failure, terminally_dispose, BackendKind, BusError,
6    ClaimOutcome, ConformanceBackend, ConnectionFailure, ContentDigest, CredentialSignFailure,
7    DurableOwner, Headers, PropertyOutcome, PropertySpec, Register, RegisterUpdate, RegisterWatch,
8    Stream, StreamCursor, WatchEvent, WorkQueue,
9};
10
11use crate::{BackendEvent, InMemoryBus, InMemoryConfig, WorkQueueConfig};
12
13pub struct InMemoryConformance;
14
15#[async_trait]
16impl ConformanceBackend for InMemoryConformance {
17    fn backend_kind(&self) -> BackendKind {
18        BackendKind::InMemory
19    }
20
21    async fn probe(&self, property: PropertySpec) -> PropertyOutcome {
22        let result = match property.name {
23            "publish_ack_monotonic_sequence" => publish_ack_sequence().await,
24            "consumer_replay_within_process_lifetime" => replay_from_cursor().await,
25            "stream_delivery_count_rises_on_each_nak" => stream_delivery_count().await,
26            "stream_term_is_never_redelivered" => stream_term().await,
27            "stream_in_progress_extends_ack_wait" => stream_in_progress().await,
28            "register_revision" => register_revision().await,
29            "register_absent_vs_empty" => register_absent_vs_empty().await,
30            "watch_snapshot_then_updates_with_connection_flag" => register_watch().await,
31            "work_redelivery_count" => work_redelivery().await,
32            "work_cap_exhaustion_and_dead_letter" => cap_exhaustion().await,
33            "full_work_queue_names_exhausted_limit" => full_queue().await,
34            "error_taxonomy_and_boundary_mapping" => error_taxonomy().await,
35            other => Err(format!("in-memory has no parity probe named {other}")),
36        };
37        match result {
38            Ok(()) => PropertyOutcome::Passed,
39            Err(reason) => PropertyOutcome::Failed { reason },
40        }
41    }
42}
43
44async fn publish_ack_sequence() -> Result<(), String> {
45    let bus = InMemoryBus::new(InMemoryConfig::default());
46    let first = bus
47        .publish("ck.box.room.a.post", "one", digest("one"), Headers::new())
48        .await
49        .map_err(debug)?;
50    let second = bus
51        .publish("ck.box.room.a.post", "two", digest("two"), Headers::new())
52        .await
53        .map_err(debug)?;
54    require(
55        !first.duplicate()
56            && !second.duplicate()
57            && second.stream_sequence() > first.stream_sequence(),
58        "publish sequence did not increase",
59    )
60}
61
62async fn replay_from_cursor() -> Result<(), String> {
63    let bus =
64        InMemoryBus::new(InMemoryConfig::default().with_durable(DurableOwner::Agent("agent")));
65    let receipt = bus
66        .publish(
67            "ck.box.peer.agent.s.deliver",
68            "m1",
69            digest("m1"),
70            Headers::new(),
71        )
72        .await
73        .map_err(debug)?;
74    require(!receipt.duplicate(), "unexpected duplicate publish")?;
75    let expected = bus
76        .consumer(DurableOwner::Agent("agent"))
77        .await
78        .map_err(debug)?
79        .next()
80        .await
81        .map_err(debug)?
82        .ok_or("first cursor saw no message")?;
83    let mut reopened = bus
84        .consumer(DurableOwner::Agent("agent"))
85        .await
86        .map_err(debug)?;
87    let replayed = reopened
88        .next()
89        .await
90        .map_err(debug)?
91        .ok_or("reopened cursor saw no replay")?;
92    require(
93        expected.stream_seq == replayed.stream_seq,
94        "cursor did not replay",
95    )?;
96    reopened.ack().await.map_err(debug)
97}
98
99const STREAM_SUBJECT: &str = "ck.box.peer.agent.s.deliver";
100
101async fn stream_delivery_count() -> Result<(), String> {
102    let bus =
103        InMemoryBus::new(InMemoryConfig::default().with_durable(DurableOwner::Agent("agent")));
104    let receipt = bus
105        .publish(STREAM_SUBJECT, "m1", digest("m1"), Headers::new())
106        .await
107        .map_err(debug)?;
108    require(!receipt.duplicate(), "unexpected duplicate publish")?;
109    let mut cursor = bus
110        .consumer(DurableOwner::Agent("agent"))
111        .await
112        .map_err(debug)?;
113    for expected in 1..=3 {
114        let delivery = cursor
115            .next()
116            .await
117            .map_err(debug)?
118            .ok_or("a nak'd message was not redelivered")?;
119        require(
120            delivery.delivery_count == expected,
121            &format!(
122                "delivery {expected} reported count {}",
123                delivery.delivery_count
124            ),
125        )?;
126        if expected < 3 {
127            cursor.nak(Duration::ZERO).await.map_err(debug)?;
128        }
129    }
130    cursor.ack().await.map_err(debug)
131}
132
133async fn stream_term() -> Result<(), String> {
134    let bus =
135        InMemoryBus::new(InMemoryConfig::default().with_durable(DurableOwner::Agent("agent")));
136    for id in ["m1", "m2"] {
137        let receipt = bus
138            .publish(STREAM_SUBJECT, id, digest(id), Headers::new())
139            .await
140            .map_err(debug)?;
141        require(!receipt.duplicate(), "unexpected duplicate publish")?;
142    }
143    let mut cursor = bus
144        .consumer(DurableOwner::Agent("agent"))
145        .await
146        .map_err(debug)?;
147    let first = cursor
148        .next()
149        .await
150        .map_err(debug)?
151        .ok_or("first message was not delivered")?;
152    require(first.message.id == "m1", "first delivery was not m1")?;
153    cursor.term(Some("body_absent")).await.map_err(debug)?;
154    let second = cursor
155        .next()
156        .await
157        .map_err(debug)?
158        .ok_or("the message after a term was not delivered")?;
159    require(
160        second.message.id == "m2" && second.delivery_count == 1,
161        "a terminated message was redelivered",
162    )?;
163    cursor.ack().await.map_err(debug)?;
164    let mut reopened = bus
165        .consumer(DurableOwner::Agent("agent"))
166        .await
167        .map_err(debug)?;
168    require(
169        reopened.next().await.map_err(debug)?.is_none(),
170        "a terminated message was redelivered to a reopened cursor",
171    )?;
172    require(
173        bus.events().iter().any(|event| {
174            matches!(
175                event,
176                BackendEvent::StreamTerminated { stream_seq, reason: Some(reason) }
177                    if *stream_seq == first.stream_seq && reason == "body_absent"
178            )
179        }),
180        "the termination and its reason were not recorded",
181    )
182}
183
184async fn stream_in_progress() -> Result<(), String> {
185    let ack_wait = Duration::from_millis(200);
186    let bus = InMemoryBus::new(
187        InMemoryConfig::default()
188            .with_durable(DurableOwner::Agent("agent"))
189            .with_stream_ack_wait(ack_wait),
190    );
191    for id in ["m1", "m2"] {
192        let receipt = bus
193            .publish(STREAM_SUBJECT, id, digest(id), Headers::new())
194            .await
195            .map_err(debug)?;
196        require(!receipt.duplicate(), "unexpected duplicate publish")?;
197    }
198    let mut slow = bus
199        .consumer(DurableOwner::Agent("agent"))
200        .await
201        .map_err(debug)?;
202    let mut other = bus
203        .consumer(DurableOwner::Agent("agent"))
204        .await
205        .map_err(debug)?;
206
207    // Control: without a progress report the message comes back to another
208    // cursor once the ack wait runs out.
209    slow.next()
210        .await
211        .map_err(debug)?
212        .ok_or("m1 was not delivered")?;
213    tokio::time::sleep(ack_wait + Duration::from_millis(100)).await;
214    let redelivered = other
215        .next()
216        .await
217        .map_err(debug)?
218        .ok_or("m1 was not redelivered after the ack wait")?;
219    require(
220        redelivered.message.id == "m1" && redelivered.delivery_count == 2,
221        "the ack-wait redelivery was not m1's second delivery",
222    )?;
223    other.ack().await.map_err(debug)?;
224    let _ = slow.ack().await;
225
226    // A progress report inside the window restarts the ack wait, so the
227    // message is not redelivered past the original deadline.
228    slow.next()
229        .await
230        .map_err(debug)?
231        .ok_or("m2 was not delivered")?;
232    tokio::time::sleep(ack_wait * 3 / 5).await;
233    slow.in_progress().await.map_err(debug)?;
234    tokio::time::sleep(ack_wait * 3 / 5).await;
235    require(
236        other.next().await.map_err(debug)?.is_none(),
237        "a message reported in progress was redelivered past the original ack wait",
238    )?;
239    slow.ack().await.map_err(debug)?;
240    require(
241        other.next().await.map_err(debug)?.is_none(),
242        "an acknowledged message was redelivered",
243    )
244}
245
246async fn register_revision() -> Result<(), String> {
247    let bus = InMemoryBus::new(InMemoryConfig::default());
248    let first = bus.put("module", b"one".to_vec()).await.map_err(debug)?;
249    let second = bus.put("module", b"two".to_vec()).await.map_err(debug)?;
250    let read = bus.get("module").await.map_err(debug)?;
251    require(second > first, "register revision did not increase")?;
252    require(read.revision == second, "get returned the wrong revision")
253}
254
255async fn register_absent_vs_empty() -> Result<(), String> {
256    let bus = InMemoryBus::new(InMemoryConfig::default());
257    bus.put("empty", Vec::new()).await.map_err(debug)?;
258    let empty = bus.get("empty").await.map_err(debug)?;
259    require(empty.value.is_empty(), "empty value was not preserved")?;
260    require(
261        matches!(bus.get("missing").await, Err(BusError::Absent { .. })),
262        "missing key was not Absent",
263    )
264}
265
266async fn register_watch() -> Result<(), String> {
267    let bus = InMemoryBus::new(InMemoryConfig::default());
268    bus.put("module.a", b"v1".to_vec()).await.map_err(debug)?;
269    let mut watch = bus.watch("module.").await.map_err(debug)?;
270    require(
271        !watch.has_snapshot(),
272        "watch claimed a snapshot before delivery",
273    )?;
274    let snapshot = watch.next().await.map_err(debug)?;
275    require(
276        matches!(snapshot, WatchEvent::Snapshot { ref entries, .. } if entries.len() == 1),
277        "watch did not deliver the complete initial snapshot",
278    )?;
279    require(
280        watch.has_snapshot(),
281        "watch did not record snapshot receipt",
282    )?;
283    bus.put("module.a", b"v2".to_vec()).await.map_err(debug)?;
284    require(
285        matches!(
286            watch.next().await.map_err(debug)?,
287            WatchEvent::Update(RegisterUpdate::Put(_))
288        ),
289        "watch did not distinguish an update",
290    )
291}
292
293async fn work_redelivery() -> Result<(), String> {
294    let bus = queue_bus(3, 4_096);
295    let receipt = bus
296        .publish(
297            "ck.box.effect.a.s.intent",
298            "m1",
299            digest("m1"),
300            Headers::new(),
301        )
302        .await
303        .map_err(debug)?;
304    require(!receipt.duplicate(), "unexpected duplicate publish")?;
305    let first = item(bus.claim().await.map_err(debug)?)?;
306    bus.nak(first.token, Duration::ZERO).await.map_err(debug)?;
307    let second = item(bus.claim().await.map_err(debug)?)?;
308    require(
309        first.delivery_count == 1,
310        "first delivery count was not one",
311    )?;
312    require(second.delivery_count == 2, "redelivery count was not two")
313}
314
315async fn cap_exhaustion() -> Result<(), String> {
316    let bus = queue_bus(1, 4_096);
317    let receipt = bus
318        .publish(
319            "ck.box.effect.a.s.intent",
320            "m1",
321            digest("m1"),
322            Headers::new(),
323        )
324        .await
325        .map_err(debug)?;
326    require(!receipt.duplicate(), "unexpected duplicate publish")?;
327    let first = item(bus.claim().await.map_err(debug)?)?;
328    bus.nak(first.token, Duration::ZERO).await.map_err(debug)?;
329    let exhausted = match bus.claim().await.map_err(debug)? {
330        ClaimOutcome::MaxDeliveriesExceeded(exhausted) => exhausted,
331        other => {
332            return Err(format!(
333                "cap did not report MaxDeliveriesExceeded: {other:?}"
334            ))
335        }
336    };
337    let dead_stream = InMemoryBus::new(InMemoryConfig::default());
338    let queue = DeadLetterObservedQueue {
339        queue: &bus,
340        dead_stream: &dead_stream,
341    };
342    let receipt = terminally_dispose(
343        &dead_stream,
344        &queue,
345        "box",
346        &exhausted,
347        digest("dead-record"),
348    )
349    .await
350    .map_err(debug)?;
351    require(!receipt.duplicate(), "dead letter was not stored")?;
352    require(
353        bus.events()
354            .iter()
355            .any(|event| matches!(event, BackendEvent::Terminated { .. })),
356        "exhausted delivery was not terminated",
357    )
358}
359
360/// Observes the separate dead-letter stream at termination time, so the probe
361/// still detects a termination that incorrectly precedes the publish.
362struct DeadLetterObservedQueue<'a> {
363    queue: &'a InMemoryBus,
364    dead_stream: &'a InMemoryBus,
365}
366
367#[async_trait]
368impl WorkQueue for DeadLetterObservedQueue<'_> {
369    async fn claim(&self) -> cortexkit_bus_trait::BusResult<ClaimOutcome> {
370        self.queue.claim().await
371    }
372
373    async fn ack(
374        &self,
375        token: cortexkit_bus_trait::DeliveryToken,
376    ) -> cortexkit_bus_trait::BusResult<()> {
377        self.queue.ack(token).await
378    }
379
380    async fn nak(
381        &self,
382        token: cortexkit_bus_trait::DeliveryToken,
383        delay: Duration,
384    ) -> cortexkit_bus_trait::BusResult<()> {
385        self.queue.nak(token, delay).await
386    }
387
388    async fn term(
389        &self,
390        token: cortexkit_bus_trait::DeliveryToken,
391    ) -> cortexkit_bus_trait::BusResult<()> {
392        if !self.dead_stream.events().iter().any(|event| {
393            matches!(event, BackendEvent::Published { subject, .. } if subject == "ck.box.effect.dead")
394        }) {
395            return Err(BusError::denied("ck.box.effect.dead", "dead-letter publish did not precede term"));
396        }
397        self.queue.term(token).await
398    }
399}
400
401async fn full_queue() -> Result<(), String> {
402    let bus = queue_bus(5, 150);
403    let receipt = bus
404        .publish(
405            "ck.box.effect.a.s.intent",
406            "first",
407            digest("first"),
408            Headers::new(),
409        )
410        .await
411        .map_err(debug)?;
412    require(!receipt.duplicate(), "unexpected duplicate publish")?;
413    let error = bus
414        .publish(
415            "ck.box.effect.a.s.intent",
416            "second",
417            digest("second"),
418            Headers::new(),
419        )
420        .await
421        .expect_err("second publish should exceed max-bytes");
422    require(
423        matches!(error, BusError::Unavailable { exhausted_limit: Some(ref limit), .. } if limit == "max_bytes"),
424        "full queue did not name max_bytes in Unavailable",
425    )
426}
427
428async fn error_taxonomy() -> Result<(), String> {
429    let denied_subject = "ck.box.room.denied.post";
430    let bus = InMemoryBus::new(
431        InMemoryConfig::default()
432            .deny_subject(denied_subject)
433            .with_work_queue(WorkQueueConfig {
434                subject: "ck.box.effect.a.s.intent".into(),
435                max_bytes: 4_096,
436                max_deliveries: 5,
437                max_nak_delay: Duration::from_millis(1),
438            }),
439    );
440    require(
441        matches!(bus.publish(denied_subject, "m", digest("m"), Headers::new()).await, Err(BusError::Denied { subject, .. }) if subject == denied_subject),
442        "fixture denial was not Denied",
443    )?;
444    require(
445        matches!(bus.get("missing").await, Err(BusError::Absent { .. })),
446        "missing register key was not Absent",
447    )?;
448    let receipt = bus
449        .publish(
450            "ck.box.effect.a.s.intent",
451            "work",
452            digest("work"),
453            Headers::new(),
454        )
455        .await
456        .map_err(debug)?;
457    require(!receipt.duplicate(), "unexpected duplicate publish")?;
458    let claimed = item(bus.claim().await.map_err(debug)?)?;
459    require(
460        matches!(
461            bus.nak(claimed.token, Duration::from_secs(1)).await,
462            Err(BusError::Clamped {
463                retry_after: Some(_),
464                ..
465            })
466        ),
467        "oversized delay was not reported Clamped",
468    )?;
469    require(
470        matches!(
471            map_connection_failure(ConnectionFailure::WriteOnSeveredSocket),
472            BusError::Unavailable { .. }
473        ),
474        "severed write was not Unavailable",
475    )?;
476    require(
477        matches!(
478            map_connection_failure(ConnectionFailure::ServerClosedThenReauthenticationRefused {
479                subject: denied_subject.into(),
480                reason: "revoked".into()
481            }),
482            BusError::Denied { .. }
483        ),
484        "reconnect refusal was not Denied",
485    )?;
486    require(
487        matches!(
488            map_credential_sign_failure(CredentialSignFailure::RecordMissing {
489                record: "nats.module.g1.e0".into()
490            }),
491            BusError::Absent { .. }
492        ),
493        "missing signing record was not Absent",
494    )
495}
496
497fn queue_bus(max_deliveries: u32, max_bytes: usize) -> InMemoryBus {
498    InMemoryBus::new(InMemoryConfig::default().with_work_queue(WorkQueueConfig {
499        subject: "ck.box.effect.a.s.intent".into(),
500        max_bytes,
501        max_deliveries,
502        max_nak_delay: Duration::from_secs(30),
503    }))
504}
505
506fn item(outcome: ClaimOutcome) -> Result<cortexkit_bus_trait::WorkItem, String> {
507    match outcome {
508        ClaimOutcome::Item(item) => Ok(item),
509        other => Err(format!("expected work item, got {other:?}")),
510    }
511}
512
513fn digest(value: &str) -> ContentDigest {
514    ContentDigest::of_bytes(value.as_bytes())
515}
516
517fn require(condition: bool, reason: &str) -> Result<(), String> {
518    condition.then_some(()).ok_or_else(|| reason.into())
519}
520
521fn debug(error: impl std::fmt::Debug) -> String {
522    format!("{error:?}")
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use cortexkit_bus_trait::{
529        classify_properties, run_conformance, PropertyApplicability, PropertySet, SuiteBug,
530        CONFORMANCE_PROPERTIES,
531    };
532
533    struct WeakenedNatsFixture;
534
535    #[async_trait]
536    impl ConformanceBackend for WeakenedNatsFixture {
537        fn backend_kind(&self) -> BackendKind {
538            BackendKind::Nats
539        }
540
541        async fn probe(&self, property: PropertySpec) -> PropertyOutcome {
542            match property.set {
543                PropertySet::Parity => InMemoryConformance.probe(property).await,
544                PropertySet::NatsOnly => PropertyOutcome::Failed {
545                    reason: "fixture deliberately has only process-local semantics".into(),
546                },
547            }
548        }
549    }
550
551    #[tokio::test]
552    async fn in_memory_passes_every_parity_property() {
553        let report = run_conformance(&InMemoryConformance, PropertySet::Parity)
554            .await
555            .expect("parity is applicable to in-memory");
556
557        assert_eq!(report.property_set_name, "parity");
558        for (property, outcome) in &report.outcomes {
559            assert_eq!(outcome, &PropertyOutcome::Passed, "{}", property.name);
560        }
561        assert!(report.passed());
562    }
563
564    #[tokio::test]
565    async fn asserting_nats_only_against_in_memory_is_a_suite_bug() {
566        let result = run_conformance(&InMemoryConformance, PropertySet::NatsOnly).await;
567        assert_eq!(result, Err(SuiteBug::NatsOnlyAssertedAgainstInMemory));
568    }
569
570    #[tokio::test]
571    async fn weakened_backend_passes_real_parity_probes_but_fails_nats_only() {
572        let parity = run_conformance(&WeakenedNatsFixture, PropertySet::Parity)
573            .await
574            .expect("parity run is valid");
575        let nats_only = run_conformance(&WeakenedNatsFixture, PropertySet::NatsOnly)
576            .await
577            .expect("NATS-only run is valid for a NATS-labelled fixture");
578
579        assert!(parity.passed());
580        assert!(!nats_only.passed());
581    }
582
583    #[test]
584    fn every_nats_only_property_is_not_applicable_not_passed() {
585        let classifications = classify_properties(BackendKind::InMemory);
586        let nats_only = classifications
587            .iter()
588            .filter(|row| row.property.set == PropertySet::NatsOnly)
589            .collect::<Vec<_>>();
590
591        assert_eq!(
592            nats_only.len(),
593            CONFORMANCE_PROPERTIES
594                .iter()
595                .filter(|property| property.set == PropertySet::NatsOnly)
596                .count()
597        );
598        assert!(nats_only.iter().all(|row| matches!(
599            row.applicability,
600            PropertyApplicability::NotApplicable { .. }
601        )));
602    }
603}