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