tangle_indexer


git clone https://radroots.dev/git/tangle_indexer.git
Log | Files | Refs | Submodules | LICENSE

commit 1a654e8ccbb736c2e80f8ad09e91f13a49c0026f
parent 377003a4c8bdb46769b279dc906792f4fffb8db3
Author: triesap <triesap@radroots.dev>
Date:   Mon, 22 Dec 2025 19:56:02 +0000

indexer: harden determinism and expand tests

- bump toolchain to 1.88 and add tempfile dev dependency
- enforce deterministic event ordering in index writes
- refactor runner cursor helpers and add cursor validation tests
- expand event parsing, resolver, and determinism integration tests

Diffstat:
MCargo.lock | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mindexer/Cargo.toml | 3+++
Mindexer/rust-toolchain.toml | 3+--
Mindexer/src/domain/events/comment.rs | 12++++++++++++
Mindexer/src/domain/events/follow.rs | 36++++++++++++++++++++++++++++++++++++
Mindexer/src/domain/events/job_feedback.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mindexer/src/domain/events/job_request.rs | 38++++++++++++++++++++++++++++++++++++++
Mindexer/src/domain/events/job_result.rs | 36++++++++++++++++++++++++++++++++++++
Mindexer/src/domain/events/post.rs | 34++++++++++++++++++++++++++++++++++
Mindexer/src/domain/events/reaction.rs | 33+++++++++++++++++++++++++++++++++
Mindexer/src/domain/indexer/models/comment.rs | 6+++++-
Mindexer/src/domain/indexer/models/follow.rs | 6+++++-
Mindexer/src/domain/indexer/models/job_feedback.rs | 6+++++-
Mindexer/src/domain/indexer/models/job_request.rs | 6+++++-
Mindexer/src/domain/indexer/models/job_result.rs | 6+++++-
Mindexer/src/domain/indexer/models/listing.rs | 6+++++-
Mindexer/src/domain/indexer/models/mod.rs | 17+++++++++++++++++
Mindexer/src/domain/indexer/models/post.rs | 6+++++-
Mindexer/src/domain/indexer/models/profile.rs | 7++++++-
Mindexer/src/domain/indexer/models/reaction.rs | 6+++++-
Mindexer/src/domain/resolvers/profile.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mindexer/src/runner.rs | 322++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mindexer/src/utils/io.rs | 29++++++++++++++++++++++++++++-
Mindexer/src/utils/nostr.rs | 29+++++++++++++++++++++++++++++
Mindexer/src/utils/strings.rs | 19+++++++++++++++++++
Aindexer/tests/indexer_determinism.rs | 406+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mrust-toolchain.toml | 3+--
27 files changed, 1190 insertions(+), 115 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -455,7 +455,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom", + "getrandom 0.2.16", "once_cell", "tiny-keccak", ] @@ -584,6 +584,16 @@ dependencies = [ ] [[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] name = "fallible-iterator" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -596,6 +606,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -653,6 +669,18 @@ dependencies = [ ] [[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -879,9 +907,9 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ "bitflags 2.9.1", "cfg-if", @@ -945,6 +973,12 @@ dependencies = [ ] [[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] name = "litemap" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1014,7 +1048,7 @@ dependencies = [ "cbc", "chacha20", "chacha20poly1305", - "getrandom", + "getrandom 0.2.16", "instant", "scrypt", "secp256k1", @@ -1283,6 +1317,12 @@ dependencies = [ ] [[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] name = "radroots-core" version = "0.1.0" dependencies = [ @@ -1338,6 +1378,7 @@ dependencies = [ "serde_json", "sha2", "sled", + "tempfile", "thiserror 1.0.69", "tokio", "tracing", @@ -1372,7 +1413,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -1501,10 +1542,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -1637,9 +1691,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "sled" @@ -1714,6 +1768,19 @@ dependencies = [ ] [[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] name = "termcolor" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2119,6 +2186,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] name = "wasm-bindgen" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2359,6 +2435,12 @@ dependencies = [ ] [[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] name = "writeable" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/indexer/Cargo.toml b/indexer/Cargo.toml @@ -34,3 +34,6 @@ rusqlite = { version = "0.32.1", features = ["bundled"] } sled = "0.34.7" sha2 = "0.10.9" nostr = "0.43.0" + +[dev-dependencies] +tempfile = "3" diff --git a/indexer/rust-toolchain.toml b/indexer/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.86.0" -\ No newline at end of file +channel = "1.88.0" diff --git a/indexer/src/domain/events/comment.rs b/indexer/src/domain/events/comment.rs @@ -325,4 +325,16 @@ mod tests { assert_eq!(comment.parent.kind, comment.root.kind); assert_eq!(comment.parent.author, comment.root.author); } + + #[test] + fn comment_parses_legacy_root_and_parent() { + let tags = vec![ + vec!["e_root".to_string(), "root123".to_string()], + vec!["e_prev".to_string(), "parent456".to_string()], + ]; + + let comment = parse_comment_from_tags(&tags, "hello").expect("parse comment"); + assert_eq!(comment.root.id, "root123"); + assert_eq!(comment.parent.id, "parent456"); + } } diff --git a/indexer/src/domain/events/follow.rs b/indexer/src/domain/events/follow.rs @@ -33,3 +33,39 @@ impl ToRadrootsFollowEventIndex for RelayIndexerEvent { Ok(index) } } + +#[cfg(test)] +mod tests { + use super::ToRadrootsFollowEventIndex; + use crate::domain::indexer::kind::IndexerEventKind; + use crate::relay::event::RelayIndexerEvent; + + fn make_event(kind: IndexerEventKind, tags: Vec<Vec<String>>) -> RelayIndexerEvent { + RelayIndexerEvent { + id: "1".repeat(64), + author: "a".repeat(64), + created_at: 10, + pubkey: "a".repeat(64), + kind, + tags, + content: String::new(), + hash: "2".repeat(64), + sig: "3".repeat(64), + } + } + + #[test] + fn follow_event_decodes_from_tags() { + let tags = vec![vec!["p".to_string(), "b".repeat(64)]]; + let event = make_event(IndexerEventKind::Follow, tags); + let index = event.to_radroots_follow_event().expect("follow index"); + assert_eq!(index.metadata.follow.list.len(), 1); + } + + #[test] + fn follow_event_rejects_wrong_kind() { + let tags = vec![vec!["p".to_string(), "b".repeat(64)]]; + let event = make_event(IndexerEventKind::Post, tags); + assert!(event.to_radroots_follow_event().is_err()); + } +} diff --git a/indexer/src/domain/events/job_feedback.rs b/indexer/src/domain/events/job_feedback.rs @@ -33,3 +33,45 @@ impl ToRadrootsJobFeedbackEventIndex for RelayIndexerEvent { Ok(index) } } + +#[cfg(test)] +mod tests { + use super::ToRadrootsJobFeedbackEventIndex; + use crate::domain::indexer::kind::IndexerEventKind; + use crate::relay::event::RelayIndexerEvent; + use radroots_events::kinds::KIND_JOB_FEEDBACK; + + fn make_event(tags: Vec<Vec<String>>) -> RelayIndexerEvent { + RelayIndexerEvent { + id: "1".repeat(64), + author: "a".repeat(64), + created_at: 10, + pubkey: "a".repeat(64), + kind: IndexerEventKind::JobFeedback, + tags, + content: String::new(), + hash: "2".repeat(64), + sig: "3".repeat(64), + } + } + + #[test] + fn job_feedback_decodes_status() { + let tags = vec![ + vec!["e".to_string(), "req123".to_string()], + vec!["status".to_string(), "success".to_string()], + ]; + let event = make_event(tags); + let index = event + .to_radroots_job_feedback_event() + .expect("job feedback index"); + assert_eq!(index.metadata.kind, KIND_JOB_FEEDBACK); + } + + #[test] + fn job_feedback_requires_status_tag() { + let tags = vec![vec!["e".to_string(), "req123".to_string()]]; + let event = make_event(tags); + assert!(event.to_radroots_job_feedback_event().is_err()); + } +} diff --git a/indexer/src/domain/events/job_request.rs b/indexer/src/domain/events/job_request.rs @@ -33,3 +33,41 @@ impl ToRadrootsJobRequestEventIndex for RelayIndexerEvent { Ok(index) } } + +#[cfg(test)] +mod tests { + use super::ToRadrootsJobRequestEventIndex; + use crate::domain::indexer::kind::IndexerEventKind; + use crate::relay::event::RelayIndexerEvent; + use radroots_events::kinds::KIND_JOB_REQUEST_MIN; + + fn make_event(kind: IndexerEventKind, tags: Vec<Vec<String>>) -> RelayIndexerEvent { + RelayIndexerEvent { + id: "1".repeat(64), + author: "a".repeat(64), + created_at: 10, + pubkey: "a".repeat(64), + kind, + tags, + content: String::new(), + hash: "2".repeat(64), + sig: "3".repeat(64), + } + } + + #[test] + fn job_request_decodes_minimal_tags() { + let event = make_event( + IndexerEventKind::JobRequest(KIND_JOB_REQUEST_MIN), + Vec::new(), + ); + let index = event.to_radroots_job_request_event().expect("job request index"); + assert_eq!(index.metadata.kind, KIND_JOB_REQUEST_MIN); + } + + #[test] + fn job_request_rejects_wrong_kind() { + let event = make_event(IndexerEventKind::Post, Vec::new()); + assert!(event.to_radroots_job_request_event().is_err()); + } +} diff --git a/indexer/src/domain/events/job_result.rs b/indexer/src/domain/events/job_result.rs @@ -33,3 +33,39 @@ impl ToRadrootsJobResultEventIndex for RelayIndexerEvent { Ok(index) } } + +#[cfg(test)] +mod tests { + use super::ToRadrootsJobResultEventIndex; + use crate::domain::indexer::kind::IndexerEventKind; + use crate::relay::event::RelayIndexerEvent; + use radroots_events::kinds::KIND_JOB_RESULT_MIN; + + fn make_event(tags: Vec<Vec<String>>) -> RelayIndexerEvent { + RelayIndexerEvent { + id: "1".repeat(64), + author: "a".repeat(64), + created_at: 10, + pubkey: "a".repeat(64), + kind: IndexerEventKind::JobResult(KIND_JOB_RESULT_MIN), + tags, + content: String::new(), + hash: "2".repeat(64), + sig: "3".repeat(64), + } + } + + #[test] + fn job_result_decodes_request_reference() { + let tags = vec![vec!["e".to_string(), "req123".to_string()]]; + let event = make_event(tags); + let index = event.to_radroots_job_result_event().expect("job result index"); + assert_eq!(index.metadata.job_result.request_event.id, "req123"); + } + + #[test] + fn job_result_requires_request_tag() { + let event = make_event(Vec::new()); + assert!(event.to_radroots_job_result_event().is_err()); + } +} diff --git a/indexer/src/domain/events/post.rs b/indexer/src/domain/events/post.rs @@ -33,3 +33,37 @@ impl ToRadrootsPostEventIndex for RelayIndexerEvent { Ok(index) } } + +#[cfg(test)] +mod tests { + use super::ToRadrootsPostEventIndex; + use crate::domain::indexer::kind::IndexerEventKind; + use crate::relay::event::RelayIndexerEvent; + + fn make_event(content: &str) -> RelayIndexerEvent { + RelayIndexerEvent { + id: "1".repeat(64), + author: "a".repeat(64), + created_at: 10, + pubkey: "a".repeat(64), + kind: IndexerEventKind::Post, + tags: Vec::new(), + content: content.to_string(), + hash: "2".repeat(64), + sig: "3".repeat(64), + } + } + + #[test] + fn post_event_decodes_from_content() { + let event = make_event("hello"); + let index = event.to_radroots_post_event().expect("post index"); + assert_eq!(index.metadata.post.content, "hello"); + } + + #[test] + fn post_event_rejects_empty_content() { + let event = make_event(""); + assert!(event.to_radroots_post_event().is_err()); + } +} diff --git a/indexer/src/domain/events/reaction.rs b/indexer/src/domain/events/reaction.rs @@ -155,3 +155,36 @@ impl ToRadrootsReactionEventIndex for RelayIndexerEvent { }) } } + +#[cfg(test)] +mod tests { + use super::parse_reaction_from_tags; + + #[test] + fn reaction_parses_event_reference() { + let tags = vec![ + vec!["e".to_string(), "root123".to_string()], + vec!["k".to_string(), "1".to_string()], + vec!["p".to_string(), "a".repeat(64)], + ]; + let reaction = parse_reaction_from_tags(&tags, "+").expect("parse reaction"); + assert_eq!(reaction.root.id, "root123"); + assert_eq!(reaction.root.kind, 1); + } + + #[test] + fn reaction_parses_address_reference() { + let pubkey = "b".repeat(64); + let addr = format!("30023:{}:dtag", pubkey); + let tags = vec![ + vec!["e".to_string(), "root123".to_string()], + vec!["a".to_string(), addr.clone()], + vec!["k".to_string(), "30023".to_string()], + vec!["p".to_string(), pubkey.clone()], + ]; + let reaction = parse_reaction_from_tags(&tags, "+").expect("parse reaction"); + assert_eq!(reaction.root.kind, 30023); + assert_eq!(reaction.root.author, pubkey); + assert_eq!(reaction.root.d_tag.as_deref(), Some("dtag")); + } +} diff --git a/indexer/src/domain/indexer/models/comment.rs b/indexer/src/domain/indexer/models/comment.rs @@ -146,7 +146,11 @@ impl WriteEventIndexes for EventCommentIndexes { { let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; } diff --git a/indexer/src/domain/indexer/models/follow.rs b/indexer/src/domain/indexer/models/follow.rs @@ -137,7 +137,11 @@ impl WriteEventIndexes for EventFollowIndexes { { let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; } diff --git a/indexer/src/domain/indexer/models/job_feedback.rs b/indexer/src/domain/indexer/models/job_feedback.rs @@ -148,7 +148,11 @@ impl WriteEventIndexes for EventJobFeedbackIndexes { { let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; } diff --git a/indexer/src/domain/indexer/models/job_request.rs b/indexer/src/domain/indexer/models/job_request.rs @@ -141,7 +141,11 @@ impl WriteEventIndexes for EventJobRequestIndexes { { let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; } diff --git a/indexer/src/domain/indexer/models/job_result.rs b/indexer/src/domain/indexer/models/job_result.rs @@ -150,7 +150,11 @@ impl WriteEventIndexes for EventJobResultIndexes { { let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; } diff --git a/indexer/src/domain/indexer/models/listing.rs b/indexer/src/domain/indexer/models/listing.rs @@ -210,7 +210,11 @@ impl WriteEventIndexes for EventListingIndexes { { let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; } diff --git a/indexer/src/domain/indexer/models/mod.rs b/indexer/src/domain/indexer/models/mod.rs @@ -23,6 +23,23 @@ use anyhow::Result; use std::path::PathBuf; use thiserror::Error; +fn sorted_event_ids<'a, E, FP, FI>( + events: &'a [E], + published_at: FP, + id: FI, +) -> Vec<&'a String> +where + FP: Fn(&E) -> u32, + FI: Fn(&E) -> &String, +{ + let mut items: Vec<(u32, &String)> = events + .iter() + .map(|event| (published_at(event), id(event))) + .collect(); + items.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1))); + items.into_iter().map(|(_, id)| id).collect() +} + #[derive(Debug, Error)] pub enum NostrEventsStaticError { #[error("Failed to build static indexes: {0}")] diff --git a/indexer/src/domain/indexer/models/post.rs b/indexer/src/domain/indexer/models/post.rs @@ -137,7 +137,11 @@ impl WriteEventIndexes for EventPostIndexes { { let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; } diff --git a/indexer/src/domain/indexer/models/profile.rs b/indexer/src/domain/indexer/models/profile.rs @@ -138,7 +138,11 @@ impl WriteEventIndexes for EventProfileIndexes { fs_mkdir(&[&base])?; let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; for &subdir in Self::subdirs().iter() { @@ -240,6 +244,7 @@ impl WriteEventIndexes for EventProfileIndexes { mod tests { use super::EventProfileIndexes; use crate::domain::indexer::kind::IndexerEventKind; + use crate::domain::indexer::models::EventIndexes; use crate::relay::event::RelayIndexerEvent; fn make_profile_event(id: &str, author: &str, created_at: u32, name: &str) -> RelayIndexerEvent { diff --git a/indexer/src/domain/indexer/models/reaction.rs b/indexer/src/domain/indexer/models/reaction.rs @@ -149,7 +149,11 @@ impl WriteEventIndexes for EventReactionIndexes { { let idxs_root = base.join("events.json"); - let ids: Vec<&String> = self.events.iter().map(|e| &e.event.id).collect(); + let ids = super::sorted_event_ids( + &self.events, + |event| event.metadata.published_at, + |event| &event.event.id, + ); write_json_if_changed(&idxs_root, &ids, updated)?; } diff --git a/indexer/src/domain/resolvers/profile.rs b/indexer/src/domain/resolvers/profile.rs @@ -17,7 +17,7 @@ pub struct ProfileResolver { impl ProfileResolver { pub fn from_metadata(raw_metadata: &[RelayIndexerEvent]) -> Self { - let mut latest: BTreeMap<String, (u32, Nip05Info)> = BTreeMap::new(); + let mut latest: BTreeMap<String, (u32, String, Nip05Info)> = BTreeMap::new(); for raw in raw_metadata { if let Ok(evt) = raw.to_radroots_profile_event() { @@ -29,27 +29,35 @@ impl ProfileResolver { let author = evt.event.author.to_lowercase(); let ts: u32 = evt.metadata.published_at; - match latest.get(&author) { - Some(&(old_ts, _)) if old_ts >= ts => {} - _ => { - latest.insert( - author, - ( - ts, - Nip05Info { - full, - local, - index_key, - }, - ), - ); + let event_id = evt.event.id.clone(); + let should_replace = match latest.get(&author) { + None => true, + Some((old_ts, old_id, _)) => { + ts > *old_ts || (ts == *old_ts && event_id < *old_id) } + }; + if should_replace { + latest.insert( + author, + ( + ts, + event_id, + Nip05Info { + full, + local, + index_key, + }, + ), + ); } } } } - let author_to_nip05 = latest.into_iter().map(|(a, (_ts, n))| (a, n)).collect(); + let author_to_nip05 = latest + .into_iter() + .map(|(a, (_ts, _id, n))| (a, n)) + .collect(); Self { author_to_nip05 } } @@ -75,3 +83,51 @@ impl ProfileResolver { .map(|info| info.local.as_str()) } } + +#[cfg(test)] +mod tests { + use super::ProfileResolver; + use crate::domain::indexer::kind::IndexerEventKind; + use crate::relay::event::RelayIndexerEvent; + + fn make_profile_event(id: &str, author: &str, created_at: u32, nip05: &str) -> RelayIndexerEvent { + let content = format!(r#"{{"name":"user","nip05":"{}"}}"#, nip05); + RelayIndexerEvent { + id: id.to_string(), + author: author.to_string(), + created_at, + pubkey: author.to_string(), + kind: IndexerEventKind::Profile, + tags: Vec::new(), + content, + hash: id.to_string(), + sig: "sig".to_string(), + } + } + + #[test] + fn resolver_tiebreaks_by_event_id() { + let author = "a".repeat(64); + let high = make_profile_event("f".repeat(64).as_str(), &author, 10, "high@radroots.market"); + let low = make_profile_event("0".repeat(64).as_str(), &author, 10, "low@radroots.market"); + + let resolver = ProfileResolver::from_metadata(&[high, low]); + let full = resolver + .nip05_full_for_author(&author) + .expect("full nip05"); + assert_eq!(full, "low@radroots.market"); + } + + #[test] + fn resolver_returns_local_and_index_key() { + let author = "b".repeat(64); + let event = make_profile_event("1".repeat(64).as_str(), &author, 10, "user@radroots.market"); + let resolver = ProfileResolver::from_metadata(&[event]); + assert_eq!( + resolver.nip05_full_for_author(&author), + Some("user@radroots.market") + ); + assert_eq!(resolver.nip05_local_for_author(&author), Some("user")); + assert_eq!(resolver.nip05_for_author(&author), Some("user")); + } +} diff --git a/indexer/src/runner.rs b/indexer/src/runner.rs @@ -42,7 +42,7 @@ const TREE_EVENTS_JOB_RESULT: &str = "job_result_events"; const TREE_EVENTS_JOB_FEEDBACK: &str = "job_feedback_events"; const TREE_STATS: &str = "stats"; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum CursorMode { RowId, CreatedAt, @@ -138,6 +138,107 @@ impl EventBatch { } } +struct RelayQueries { + created: String, + rowid: String, +} + +fn build_relay_queries() -> RelayQueries { + let relay_kind_filter = IndexerEventKind::relay_kind_filter_sql(); + let created = format!( + "SELECT hex(event_hash), hex(author), created_at, kind, content FROM event WHERE ({}) AND (created_at > ? OR (created_at = ? AND hex(event_hash) > ?)) ORDER BY created_at ASC, hex(event_hash) ASC", + relay_kind_filter + ); + let rowid = format!( + "SELECT rowid, hex(event_hash), hex(author), created_at, kind, content FROM event WHERE ({}) AND rowid > ? ORDER BY rowid ASC", + relay_kind_filter + ); + RelayQueries { created, rowid } +} + +fn resolve_cursor_mode(relay_db: &rusqlite::Connection, rowid_query: &str) -> CursorMode { + match sqlite_stmt(relay_db, rowid_query) { + Ok(_) => CursorMode::RowId, + Err(err) => { + warn!( + error = %err, + "Rowid cursor unavailable, falling back to created_at cursor" + ); + CursorMode::CreatedAt + } + } +} + +fn load_records( + relay_db: &rusqlite::Connection, + mode: CursorMode, + queries: &RelayQueries, + cursor: &CursorState, +) -> Result<Vec<RelayEventRecord>> { + match mode { + CursorMode::RowId => { + let mut stmt = sqlite_stmt(relay_db, &queries.rowid) + .context("Could not prepare rowid event query")?; + let rows = stmt.query_map( + params![cursor.last_rowid], + RelayEventRecord::from_row_with_rowid, + )?; + rows.collect::<Result<Vec<_>, _>>() + .context("collecting RelayEventRecord rows") + } + CursorMode::CreatedAt => { + let mut stmt = sqlite_stmt(relay_db, &queries.created) + .context("Could not prepare created_at event query")?; + let rows = stmt.query_map( + params![ + cursor.last_created_at, + cursor.last_created_at, + &cursor.last_event_hash + ], + RelayEventRecord::from_row, + )?; + rows.collect::<Result<Vec<_>, _>>() + .context("collecting RelayEventRecord rows") + } + } +} + +fn update_cursor( + db_idx: &IndexerDb, + cursor: &mut CursorState, + mode: CursorMode, + batch: &mut EventBatch, +) -> Result<bool> { + let mut cursor_updated = false; + match mode { + CursorMode::CreatedAt => { + if let Some((created_at, event_hash)) = batch.next_created.take() { + cursor.last_created_at = created_at; + cursor.last_event_hash = event_hash; + db_idx.insert_raw( + TREE_STATS, + "last_created_at", + &cursor.last_created_at.to_be_bytes(), + )?; + db_idx.insert_raw( + TREE_STATS, + "last_event_hash", + cursor.last_event_hash.as_bytes(), + )?; + cursor_updated = true; + } + } + CursorMode::RowId => { + if let Some(rowid) = batch.next_rowid.take() { + cursor.last_rowid = rowid; + db_idx.insert_raw(TREE_STATS, "last_rowid", &cursor.last_rowid.to_be_bytes())?; + cursor_updated = true; + } + } + } + Ok(cursor_updated) +} + #[derive(Default)] struct ChangeFlags { profiles: bool, @@ -223,16 +324,7 @@ fn write_indexes<T: WriteEventIndexes>( pub async fn run(settings: Settings) -> Result<()> { let db_idx = IndexerDb::open(&format!("{}/indexer_db", settings.indexer.data_dir))?; let mut cursor = CursorState::load(&db_idx)?; - - let relay_kind_filter = IndexerEventKind::relay_kind_filter_sql(); - let relay_query_created = format!( - "SELECT hex(event_hash), hex(author), created_at, kind, content FROM event WHERE ({}) AND (created_at > ? OR (created_at = ? AND hex(event_hash) > ?)) ORDER BY created_at ASC, hex(event_hash) ASC", - relay_kind_filter - ); - let relay_query_rowid = format!( - "SELECT rowid, hex(event_hash), hex(author), created_at, kind, content FROM event WHERE ({}) AND rowid > ? ORDER BY rowid ASC", - relay_kind_filter - ); + let relay_queries = build_relay_queries(); let mut profiles = ProfileResolver::default(); let mut profiles_loaded = false; @@ -247,43 +339,11 @@ pub async fn run(settings: Settings) -> Result<()> { ) })?; if cursor_mode.is_none() { - cursor_mode = match sqlite_stmt(&relay_db, &relay_query_rowid) { - Ok(_) => Some(CursorMode::RowId), - Err(err) => { - warn!( - error = %err, - "Rowid cursor unavailable, falling back to created_at cursor" - ); - Some(CursorMode::CreatedAt) - } - }; + cursor_mode = Some(resolve_cursor_mode(&relay_db, &relay_queries.rowid)); } let mode = cursor_mode.unwrap_or(CursorMode::CreatedAt); - let records: Vec<RelayEventRecord> = match mode { - CursorMode::RowId => { - let mut stmt = sqlite_stmt(&relay_db, &relay_query_rowid) - .context("Could not prepare rowid event query")?; - let rows = - stmt.query_map(params![cursor.last_rowid], RelayEventRecord::from_row_with_rowid)?; - rows.collect::<Result<Vec<_>, _>>() - .context("collecting RelayEventRecord rows")? - } - CursorMode::CreatedAt => { - let mut stmt = sqlite_stmt(&relay_db, &relay_query_created) - .context("Could not prepare created_at event query")?; - let rows = stmt.query_map( - params![ - cursor.last_created_at, - cursor.last_created_at, - &cursor.last_event_hash - ], - RelayEventRecord::from_row, - )?; - rows.collect::<Result<Vec<_>, _>>() - .context("collecting RelayEventRecord rows")? - } - }; + let records = load_records(&relay_db, mode, &relay_queries, &cursor)?; let mut batch = EventBatch::from_records(records)?; info!(record_count = batch.record_count, "Loaded relay records"); @@ -438,37 +498,7 @@ pub async fn run(settings: Settings) -> Result<()> { write_indexes(&settings, Some("listing"), listing_indexes)?; } - let mut cursor_updated = false; - match mode { - CursorMode::CreatedAt => { - if let Some((created_at, event_hash)) = batch.next_created.take() { - cursor.last_created_at = created_at; - cursor.last_event_hash = event_hash; - db_idx.insert_raw( - TREE_STATS, - "last_created_at", - &cursor.last_created_at.to_be_bytes(), - )?; - db_idx.insert_raw( - TREE_STATS, - "last_event_hash", - cursor.last_event_hash.as_bytes(), - )?; - cursor_updated = true; - } - } - CursorMode::RowId => { - if let Some(rowid) = batch.next_rowid.take() { - cursor.last_rowid = rowid; - db_idx.insert_raw( - TREE_STATS, - "last_rowid", - &cursor.last_rowid.to_be_bytes(), - )?; - cursor_updated = true; - } - } - } + let cursor_updated = update_cursor(&db_idx, &mut cursor, mode, &mut batch)?; if cursor_updated { db_idx.flush()?; } @@ -484,3 +514,143 @@ pub async fn run(settings: Settings) -> Result<()> { tokio::time::sleep(delay).await; } } + +#[cfg(test)] +mod tests { + use super::{ + build_relay_queries, parse_string, parse_u32, parse_u64, resolve_cursor_mode, update_cursor, + CursorMode, CursorState, EventBatch, + }; + use crate::domain::indexer::kind::IndexerEventKind; + use crate::relay::event::RelayRawEvent; + use crate::relay::record::RelayEventRecord; + use crate::utils::db::IndexerDb; + use radroots_events::kinds::KIND_JOB_REQUEST_MIN; + use std::collections::HashMap; + use tempfile::tempdir; + + fn make_record(rowid: u64, event_hash: &str, author: &str, created_at: u32, kind: u32) -> RelayEventRecord { + let raw = RelayRawEvent { + id: event_hash.to_string(), + pubkey: author.to_string(), + created_at, + kind, + tags: Vec::new(), + content: "hello".to_string(), + sig: "sig".to_string(), + }; + let content = serde_json::to_string(&raw).expect("json"); + RelayEventRecord { + rowid: Some(rowid), + event_hash: event_hash.to_string(), + author: author.to_string(), + created_at, + kind: IndexerEventKind::try_from(kind as u64).expect("kind"), + content, + } + } + + #[test] + fn parse_helpers_reject_invalid_lengths() { + assert!(parse_u32(&[0u8; 3], "u32").is_none()); + assert!(parse_u64(&[0u8; 7], "u64").is_none()); + } + + #[test] + fn parse_string_rejects_invalid_utf8() { + assert!(parse_string(&[0xff, 0xfe], "str").is_none()); + } + + #[test] + fn event_batch_groups_job_request_kinds() { + let author = "a".repeat(64); + let rec = make_record( + 1, + "1".repeat(64).as_str(), + &author, + 10, + KIND_JOB_REQUEST_MIN + 1, + ); + let batch = EventBatch::from_records(vec![rec]).expect("batch"); + assert!(batch + .events_by_kind + .contains_key(&IndexerEventKind::JobRequest(KIND_JOB_REQUEST_MIN))); + } + + #[test] + fn resolve_cursor_mode_falls_back_without_rowid() { + let conn = rusqlite::Connection::open_in_memory().expect("conn"); + conn.execute( + "CREATE TABLE event (event_hash BLOB PRIMARY KEY, author BLOB, created_at INTEGER, kind INTEGER, content TEXT) WITHOUT ROWID", + [], + ) + .expect("create table"); + let queries = build_relay_queries(); + let mode = resolve_cursor_mode(&conn, &queries.rowid); + assert_eq!(mode, CursorMode::CreatedAt); + } + + #[test] + fn resolve_cursor_mode_uses_rowid_when_available() { + let conn = rusqlite::Connection::open_in_memory().expect("conn"); + conn.execute( + "CREATE TABLE event (event_hash BLOB, author BLOB, created_at INTEGER, kind INTEGER, content TEXT)", + [], + ) + .expect("create table"); + let queries = build_relay_queries(); + let mode = resolve_cursor_mode(&conn, &queries.rowid); + assert_eq!(mode, CursorMode::RowId); + } + + #[test] + fn update_cursor_writes_created_at_state() { + let dir = tempdir().expect("tempdir"); + let db_idx = IndexerDb::open(dir.path().join("db").to_str().expect("path")) + .expect("open db"); + let mut cursor = CursorState::default(); + let mut batch = EventBatch { + events_by_kind: HashMap::new(), + next_created: Some((42, "hash".to_string())), + next_rowid: None, + record_count: 0, + }; + + let updated = update_cursor(&db_idx, &mut cursor, CursorMode::CreatedAt, &mut batch) + .expect("update cursor"); + assert!(updated); + assert_eq!(cursor.last_created_at, 42); + assert_eq!(cursor.last_event_hash, "hash"); + db_idx.flush().expect("flush"); + let stored = db_idx + .get_raw("stats", "last_created_at") + .expect("get raw") + .expect("value"); + assert_eq!(stored.as_ref(), &42u32.to_be_bytes()); + } + + #[test] + fn update_cursor_writes_rowid_state() { + let dir = tempdir().expect("tempdir"); + let db_idx = IndexerDb::open(dir.path().join("db").to_str().expect("path")) + .expect("open db"); + let mut cursor = CursorState::default(); + let mut batch = EventBatch { + events_by_kind: HashMap::new(), + next_created: None, + next_rowid: Some(7), + record_count: 0, + }; + + let updated = update_cursor(&db_idx, &mut cursor, CursorMode::RowId, &mut batch) + .expect("update cursor"); + assert!(updated); + assert_eq!(cursor.last_rowid, 7); + db_idx.flush().expect("flush"); + let stored = db_idx + .get_raw("stats", "last_rowid") + .expect("get raw") + .expect("value"); + assert_eq!(stored.as_ref(), &7u64.to_be_bytes()); + } +} diff --git a/indexer/src/utils/io.rs b/indexer/src/utils/io.rs @@ -130,7 +130,9 @@ pub fn fs_write_rss(path: &Path, content: &str) -> Result<()> { #[cfg(test)] mod tests { - use super::safe_path_segment; + use super::{safe_path_segment, write_json_if_changed}; + use std::fs; + use tempfile::tempdir; #[test] fn safe_path_segment_rejects_traversal() { @@ -148,4 +150,29 @@ mod tests { Some("user@example.com".to_string()) ); } + + #[test] + fn write_json_if_changed_is_idempotent() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("data.json"); + let mut updated = Vec::new(); + + let hash_a = write_json_if_changed(&path, &vec![1u32, 2, 3], &mut updated) + .expect("write json"); + assert_eq!(updated.len(), 1); + let first = fs::read_to_string(&path).expect("read data"); + + updated.clear(); + let hash_b = write_json_if_changed(&path, &vec![1u32, 2, 3], &mut updated) + .expect("write json"); + assert_eq!(hash_a, hash_b); + assert!(updated.is_empty()); + let second = fs::read_to_string(&path).expect("read data"); + assert_eq!(first, second); + + updated.clear(); + let _hash_c = write_json_if_changed(&path, &vec![1u32, 2, 4], &mut updated) + .expect("write json"); + assert_eq!(updated.len(), 1); + } } diff --git a/indexer/src/utils/nostr.rs b/indexer/src/utils/nostr.rs @@ -47,3 +47,32 @@ pub fn get_tag_value<'a>( ))), } } + +#[cfg(test)] +mod tests { + use super::normalize_nip05; + + #[test] + fn normalize_nip05_lowercases_and_extracts_parts() { + let (full, local, index_key) = normalize_nip05("Alice@Radroots.Market"); + assert_eq!(full, "alice@radroots.market"); + assert_eq!(local, "alice"); + assert_eq!(index_key, "alice"); + } + + #[test] + fn normalize_nip05_without_domain_keeps_value() { + let (full, local, index_key) = normalize_nip05("User"); + assert_eq!(full, "user"); + assert_eq!(local, "user"); + assert_eq!(index_key, "user"); + } + + #[test] + fn normalize_nip05_non_radroots_domain_keeps_index_key() { + let (full, local, index_key) = normalize_nip05("bob@example.com"); + assert_eq!(full, "bob@example.com"); + assert_eq!(local, "bob"); + assert_eq!(index_key, "bob@example.com"); + } +} diff --git a/indexer/src/utils/strings.rs b/indexer/src/utils/strings.rs @@ -9,3 +9,22 @@ pub fn truncate_log(s: &str, max: usize) -> &str { s } } + +#[cfg(test)] +mod tests { + use super::truncate_log; + + #[test] + fn truncate_log_no_change_when_under_limit() { + let value = "alpha"; + assert_eq!(truncate_log(value, 10), value); + } + + #[test] + fn truncate_log_respects_char_boundary() { + let value = "a✓b"; + assert_eq!(truncate_log(value, 2), "a"); + assert_eq!(truncate_log(value, 3), "a"); + assert_eq!(truncate_log(value, 4), "a✓"); + } +} diff --git a/indexer/tests/indexer_determinism.rs b/indexer/tests/indexer_determinism.rs @@ -0,0 +1,406 @@ +use radroots_market_indexer::config::{Indexer, Listings, Relay, Settings}; +use radroots_market_indexer::domain::indexer::kind::IndexerEventKind; +use radroots_market_indexer::domain::indexer::models::{ + EventCommentIndexes, EventFollowIndexes, EventIndexes, EventJobFeedbackIndexes, + EventJobRequestIndexes, EventJobResultIndexes, EventListingIndexes, EventPostIndexes, + EventProfileIndexes, EventReactionIndexes, WriteEventIndexes, +}; +use radroots_market_indexer::domain::resolvers::profile::ProfileResolver; +use radroots_market_indexer::relay::event::RelayIndexerEvent; +use radroots_events::kinds::{KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN}; +use std::path::Path; +use tempfile::tempdir; + +fn settings_for(root: &Path) -> Settings { + Settings { + indexer: Indexer { + data_dir: root.join("data").to_string_lossy().to_string(), + logs_dir: root.join("logs").to_string_lossy().to_string(), + flush_interval: 1, + }, + relay: Relay { + url: String::new(), + database_path: String::new(), + }, + listings: Listings { + country_shard_size: 0, + profile_shard_size: 0, + }, + } +} + +fn write_ids<T: WriteEventIndexes>( + indexes: &T, + settings: &Settings, + kind: IndexerEventKind, +) -> Vec<String> { + let mut updated = Vec::new(); + indexes.write(settings, &mut updated).expect("write indexes"); + let path = kind + .base_path(&settings.indexer.data_dir) + .expect("base path") + .join("events.json"); + let raw = std::fs::read_to_string(path).expect("read events.json"); + serde_json::from_str(&raw).expect("parse events.json") +} + +fn expected_ids(events: &[RelayIndexerEvent]) -> Vec<String> { + let mut refs: Vec<&RelayIndexerEvent> = events.iter().collect(); + refs.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id))); + refs.into_iter().map(|e| e.id.clone()).collect() +} + +fn assert_deterministic<T, F>( + kind: IndexerEventKind, + events: Vec<RelayIndexerEvent>, + build: F, +) where + T: WriteEventIndexes, + F: Fn(&[RelayIndexerEvent]) -> T, +{ + let mut reversed = events.clone(); + reversed.reverse(); + let expected = expected_ids(&events); + + let dir_a = tempdir().expect("tempdir"); + let settings_a = settings_for(dir_a.path()); + let dir_b = tempdir().expect("tempdir"); + let settings_b = settings_for(dir_b.path()); + + let indexes_a = build(&events); + let indexes_b = build(&reversed); + + let ids_a = write_ids(&indexes_a, &settings_a, kind); + let ids_b = write_ids(&indexes_b, &settings_b, kind); + + assert_eq!(ids_a, expected); + assert_eq!(ids_b, expected); +} + +fn make_event( + id: &str, + author: &str, + created_at: u32, + kind: IndexerEventKind, + tags: Vec<Vec<String>>, + content: &str, +) -> RelayIndexerEvent { + RelayIndexerEvent { + id: id.to_string(), + author: author.to_string(), + created_at, + pubkey: author.to_string(), + kind, + tags, + content: content.to_string(), + hash: id.to_string(), + sig: "sig".to_string(), + } +} + +fn profile_event(id: &str, author: &str, created_at: u32, nip05: &str) -> RelayIndexerEvent { + let content = serde_json::json!({"name": "user", "nip05": nip05}).to_string(); + make_event( + id, + author, + created_at, + IndexerEventKind::Profile, + Vec::new(), + &content, + ) +} + +fn listing_tags(d_tag: &str) -> Vec<Vec<String>> { + vec![ + vec!["d".to_string(), d_tag.to_string()], + vec!["key".to_string(), "key".to_string()], + vec!["title".to_string(), "title".to_string()], + vec!["category".to_string(), "category".to_string()], + ] +} + +fn comment_tags(root_id: &str, root_author: &str) -> Vec<Vec<String>> { + vec![ + vec!["E".to_string(), root_id.to_string()], + vec!["K".to_string(), "1".to_string()], + vec!["P".to_string(), root_author.to_string()], + ] +} + +fn reaction_tags(root_id: &str, root_author: &str) -> Vec<Vec<String>> { + vec![ + vec!["e".to_string(), root_id.to_string()], + vec!["k".to_string(), "1".to_string()], + vec!["p".to_string(), root_author.to_string()], + ] +} + +#[test] +fn events_json_deterministic_profile() { + let author = "a".repeat(64); + let events = vec![ + profile_event("1".repeat(64).as_str(), &author, 10, "a@radroots.market"), + profile_event("2".repeat(64).as_str(), &author, 20, "b@radroots.market"), + ]; + assert_deterministic(IndexerEventKind::Profile, events, |raw| { + EventProfileIndexes::build(raw).expect("build profile") + }); +} + +#[test] +fn events_json_deterministic_listing() { + let author = "b".repeat(64); + let events = vec![ + make_event( + "1".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::Listing, + listing_tags("d1"), + "", + ), + make_event( + "2".repeat(64).as_str(), + &author, + 20, + IndexerEventKind::Listing, + listing_tags("d2"), + "", + ), + ]; + let profiles = ProfileResolver::default(); + assert_deterministic(IndexerEventKind::Listing, events, |raw| { + EventListingIndexes::build_with_profiles(raw, &profiles).expect("build listing") + }); +} + +#[test] +fn events_json_deterministic_comment() { + let author = "c".repeat(64); + let root_author = "d".repeat(64); + let events = vec![ + make_event( + "1".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::Comment, + comment_tags("root1", &root_author), + "hello", + ), + make_event( + "2".repeat(64).as_str(), + &author, + 20, + IndexerEventKind::Comment, + comment_tags("root2", &root_author), + "hi", + ), + ]; + let profiles = ProfileResolver::default(); + assert_deterministic(IndexerEventKind::Comment, events, |raw| { + EventCommentIndexes::build_with_profiles(raw, &profiles).expect("build comment") + }); +} + +#[test] +fn events_json_deterministic_reaction() { + let author = "e".repeat(64); + let root_author = "f".repeat(64); + let events = vec![ + make_event( + "1".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::Reaction, + reaction_tags("root1", &root_author), + "+", + ), + make_event( + "2".repeat(64).as_str(), + &author, + 20, + IndexerEventKind::Reaction, + reaction_tags("root2", &root_author), + "+", + ), + ]; + let profiles = ProfileResolver::default(); + assert_deterministic(IndexerEventKind::Reaction, events, |raw| { + EventReactionIndexes::build_with_profiles(raw, &profiles).expect("build reaction") + }); +} + +#[test] +fn events_json_deterministic_post() { + let author = "1".repeat(64); + let events = vec![ + make_event( + "a".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::Post, + Vec::new(), + "hello", + ), + make_event( + "b".repeat(64).as_str(), + &author, + 20, + IndexerEventKind::Post, + Vec::new(), + "hi", + ), + ]; + let profiles = ProfileResolver::default(); + assert_deterministic(IndexerEventKind::Post, events, |raw| { + EventPostIndexes::build_with_profiles(raw, &profiles).expect("build post") + }); +} + +#[test] +fn events_json_deterministic_follow() { + let author = "2".repeat(64); + let follow = "3".repeat(64); + let events = vec![ + make_event( + "a".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::Follow, + vec![vec!["p".to_string(), follow.clone()]], + "", + ), + make_event( + "b".repeat(64).as_str(), + &author, + 20, + IndexerEventKind::Follow, + vec![vec!["p".to_string(), follow]], + "", + ), + ]; + let profiles = ProfileResolver::default(); + assert_deterministic(IndexerEventKind::Follow, events, |raw| { + EventFollowIndexes::build_with_profiles(raw, &profiles).expect("build follow") + }); +} + +#[test] +fn events_json_deterministic_job_request() { + let author = "4".repeat(64); + let events = vec![ + make_event( + "a".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::JobRequest(KIND_JOB_REQUEST_MIN), + Vec::new(), + "", + ), + make_event( + "b".repeat(64).as_str(), + &author, + 20, + IndexerEventKind::JobRequest(KIND_JOB_REQUEST_MIN), + Vec::new(), + "", + ), + ]; + let profiles = ProfileResolver::default(); + assert_deterministic( + IndexerEventKind::JobRequest(KIND_JOB_REQUEST_MIN), + events, + |raw| EventJobRequestIndexes::build_with_profiles(raw, &profiles).expect("build job request"), + ); +} + +#[test] +fn events_json_deterministic_job_result() { + let author = "5".repeat(64); + let events = vec![ + make_event( + "a".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::JobResult(KIND_JOB_RESULT_MIN), + vec![vec!["e".to_string(), "req1".to_string()]], + "", + ), + make_event( + "b".repeat(64).as_str(), + &author, + 20, + IndexerEventKind::JobResult(KIND_JOB_RESULT_MIN), + vec![vec!["e".to_string(), "req2".to_string()]], + "", + ), + ]; + let profiles = ProfileResolver::default(); + assert_deterministic( + IndexerEventKind::JobResult(KIND_JOB_RESULT_MIN), + events, + |raw| EventJobResultIndexes::build_with_profiles(raw, &profiles).expect("build job result"), + ); +} + +#[test] +fn events_json_deterministic_job_feedback() { + let author = "6".repeat(64); + let events = vec![ + make_event( + "a".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::JobFeedback, + vec![ + vec!["e".to_string(), "req1".to_string()], + vec!["status".to_string(), "success".to_string()], + ], + "", + ), + make_event( + "b".repeat(64).as_str(), + &author, + 20, + IndexerEventKind::JobFeedback, + vec![ + vec!["e".to_string(), "req2".to_string()], + vec!["status".to_string(), "success".to_string()], + ], + "", + ), + ]; + let profiles = ProfileResolver::default(); + assert_deterministic(IndexerEventKind::JobFeedback, events, |raw| { + EventJobFeedbackIndexes::build_with_profiles(raw, &profiles).expect("build job feedback") + }); +} + +#[test] +fn events_json_tiebreaks_by_id() { + let author = "7".repeat(64); + let events = vec![ + make_event( + "f".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::Post, + Vec::new(), + "hello", + ), + make_event( + "0".repeat(64).as_str(), + &author, + 10, + IndexerEventKind::Post, + Vec::new(), + "world", + ), + ]; + let profiles = ProfileResolver::default(); + let dir = tempdir().expect("tempdir"); + let settings = settings_for(dir.path()); + let indexes = EventPostIndexes::build_with_profiles(&events, &profiles).expect("build post"); + let ids = write_ids(&indexes, &settings, IndexerEventKind::Post); + assert_eq!(ids[0], "0".repeat(64)); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.86.0" -\ No newline at end of file +channel = "1.88.0"