Skip to main content

cortexkit_bus_inmemory/
conformance.rs

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