Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/ourios-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//! Foundational types for Ourios.

pub mod config;
pub mod tenant;
51 changes: 51 additions & 0 deletions crates/ourios-core/src/tenant.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Tenant identity for the multi-tenant miner.
//!
//! `[CLAUDE.md §3.7]`: every code path that touches data takes a
//! tenant id. This module ships the type; routing, storage, and
//! per-tenant state live in the consuming crates.

use std::fmt;

/// An opaque, operator-facing tenant identifier.
///
/// Backed by a `String` because tenant ids in deployed systems
/// are usually slugs (`"acme-corp"`), UUIDs, or k8s-style names —
/// human-readable matters more at this layer than column-store
/// width. A future `TenantIdHash` newtype may carry a fixed-width
/// hash for Parquet column efficiency, but only once the writer
/// crate exists and we have a benchmark that asks for it.
///
/// Equality is byte-for-byte (`String` `Eq`) — `"Acme"` and
/// `"acme"` are distinct tenants. No normalisation, no folding,
/// no validation. If a downstream caller wants validation
/// (reject empty, reject control characters), it can layer a
/// `try_new` constructor on top; we don't preempt that contract.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TenantId(String);

impl TenantId {
/// Wrap an owned or borrowed string into a `TenantId`. No
/// validation — see the type-level note.
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}

/// Borrow the underlying string. Useful for log messages,
/// metric labels, and any code that needs the raw bytes.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}

impl AsRef<str> for TenantId {
fn as_ref(&self) -> &str {
&self.0
}
}

impl fmt::Display for TenantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
2 changes: 1 addition & 1 deletion crates/ourios-miner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ license.workspace = true
repository.workspace = true
publish = false

[dev-dependencies]
[dependencies]
ourios-core = { path = "../ourios-core" }

[lints]
Expand Down
222 changes: 222 additions & 0 deletions crates/ourios-miner/src/cluster.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
//! Per-tenant template cluster.
//!
//! Holds one [`TenantState`] per [`TenantId`] (`[CLAUDE.md §3.7]`):
//! every ingested line is keyed on its tenant, and per-tenant
//! template *stores* are isolated — no template ever crosses
//! tenants. The `template_id` allocator, by contrast, is
//! **cluster-wide** so the same `u64` value never refers to two
//! different leaves (RFC 0001 §6.1, §5 §3.7.2); each tenant
//! sees a monotonic *subsequence* of the shared id space.
//!
//! What this module is NOT (yet):
//!
//! - It is **not** Drain. The per-tenant store is a
//! [`HashMap`] keyed on the masked-token sequence: lines that
//! produce structurally identical masked sequences share a
//! `template_id`, lines that differ in any position get
//! distinct ids. This is exact-match templating; future PRs
//! replace the `HashMap` with `simSeq` + the depth-bounded
//! tree + widening (RFC 0001 §6.2 steps 3–5). The §3.7
//! isolation invariant is testable at this layer because
//! isolation is about *who owns which store*, not about how
//! the store clusters.
//! - It does not emit audit events, telemetry, body retention,
//! or `lossy_flag` — all those follow once the tree exists.
//! - It does not write Parquet records — the on-disk shape is
//! `ourios-parquet`'s problem.

use std::collections::HashMap;

use ourios_core::config::MinerConfig;
use ourios_core::tenant::TenantId;

use crate::mask::mask;
use crate::tokenize::tokenize;

/// A multi-tenant in-memory miner.
///
/// Holds one [`TenantState`] per [`TenantId`]; per-tenant state
/// is allocated lazily on the first `ingest` call for that
/// tenant. Tenant deprovisioning (`TenantPaused`,
/// `TenantDeleted`) is RFC 0001 §9 territory and not in this
/// type's API yet.
pub struct MinerCluster {
config: MinerConfig,
tenants: HashMap<TenantId, TenantState>,
// Cluster-wide template_id allocator. RFC 0001 §6.1 calls
// template_id "per-tenant monotonic" but also requires that
// "two tenants emitting the structurally identical template
// will have different template_ids" (and §5 §3.7.2: "no
// template_id is shared across tenants"). A truly per-tenant
// allocator gives both tenants id=1 for their first template
// and silently violates §3.7.2. The reconciliation: the id
// *space* is cluster-wide, but each tenant's slice of that
// space is monotonic with respect to that tenant's allocation
// order — both invariants hold.
next_template_id: u64,
}

/// Per-tenant template store.
///
/// Private: the cross-tenant API surface lives on
/// [`MinerCluster`]; per-tenant access goes through the cluster
/// helpers below. Future PRs will give this struct real Drain
/// machinery; the current `HashMap<Vec<String>, u64>` is the
/// simplest representation that satisfies §3.7's isolation
/// contract.
///
/// `template_id` allocation lives on [`MinerCluster`], not here
/// — see the `next_template_id` comment there for why.
struct TenantState {
templates: HashMap<Vec<String>, u64>,
}

impl TenantState {
fn new() -> Self {
Self {
templates: HashMap::new(),
}
}
}

impl MinerCluster {
/// Build an empty cluster. No tenant state allocated until
/// the first `ingest` for a given tenant.
#[must_use]
pub fn new(config: MinerConfig) -> Self {
Self {
config,
tenants: HashMap::new(),
// Start at 1 so 0 stays available as a sentinel for
// "no template" if a future caller wants one.
next_template_id: 1,
}
}

/// Borrow the cluster's [`MinerConfig`].
///
/// All tenants currently share one config; per-tenant
/// overrides are a future PR.
#[must_use]
pub fn config(&self) -> &MinerConfig {
&self.config
}

/// Ingest a raw line for the named tenant. Returns the
/// `template_id` allocated (or reused) for the line's
/// masked shape.
///
/// On first sight of `tenant_id`, allocates a fresh
/// per-tenant store. Lines whose masked-token sequence has
/// been seen before reuse the existing `template_id`; new
/// shapes pull the next monotonic id from the cluster-wide
/// allocator.
pub fn ingest(&mut self, tenant_id: &TenantId, raw: &str) -> u64 {
let tokenized = tokenize(raw);
let masked = mask(&tokenized.tokens);
let masked_owned: Vec<String> = masked.tokens.into_iter().map(String::from).collect();

// Two-phase to keep the borrow checker happy: lookup
// borrows self.tenants immutably for the early-return,
// then the allocate-and-insert path borrows self twice
// (next_template_id mutably, tenants mutably).
if let Some(state) = self.tenants.get(tenant_id) {
if let Some(&id) = state.templates.get(&masked_owned) {
return id;
}
}

let new_id = self.next_template_id;
self.next_template_id += 1;
let state = self
.tenants
.entry(tenant_id.clone())
.or_insert_with(TenantState::new);
state.templates.insert(masked_owned, new_id);
new_id
}

/// Number of distinct templates this tenant has accumulated.
/// Returns 0 for a tenant the cluster has never seen.
#[must_use]
pub fn template_count(&self, tenant_id: &TenantId) -> usize {
self.tenants.get(tenant_id).map_or(0, |s| s.templates.len())
}

/// Snapshot of `(masked_template, template_id)` pairs for
/// one tenant. Returns an empty vec for unseen tenants.
///
/// Order is not guaranteed (`HashMap` iteration). Callers
/// that need a stable order should sort.
#[must_use]
pub fn templates_for(&self, tenant_id: &TenantId) -> Vec<(Vec<String>, u64)> {
self.tenants.get(tenant_id).map_or_else(Vec::new, |s| {
s.templates.iter().map(|(t, id)| (t.clone(), *id)).collect()
})
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ingest_returns_same_template_id_for_repeat_shape() {
// Arrange — two lines with the same masked shape.
let mut cluster = MinerCluster::new(MinerConfig::default());
let t = TenantId::new("tenant-x");

// Act
let id1 = cluster.ingest(&t, "user 42 logged in");
let id2 = cluster.ingest(&t, "user 17 logged in");

// Assert — exact-match templating: <NUM> abstracts the
// user id, so both lines mask to the same shape and
// share a template_id.
assert_eq!(id1, id2);
assert_eq!(cluster.template_count(&t), 1);
}

#[test]
fn ingest_returns_distinct_template_ids_for_distinct_shapes() {
// Arrange
let mut cluster = MinerCluster::new(MinerConfig::default());
let t = TenantId::new("tenant-x");

// Act
let id1 = cluster.ingest(&t, "user 42 logged in");
let id2 = cluster.ingest(&t, "GET /home 200");

// Assert
assert_ne!(id1, id2);
assert_eq!(cluster.template_count(&t), 2);
}

#[test]
fn template_count_is_zero_for_unseen_tenant() {
// Arrange
let cluster = MinerCluster::new(MinerConfig::default());
let unseen = TenantId::new("never-ingested");

// Act
let n = cluster.template_count(&unseen);

// Assert
assert_eq!(n, 0);
assert!(cluster.templates_for(&unseen).is_empty());
}

#[test]
fn ingest_lazily_allocates_per_tenant_state() {
// Arrange — a fresh cluster has no tenants.
let mut cluster = MinerCluster::new(MinerConfig::default());
let t = TenantId::new("tenant-x");
assert_eq!(cluster.template_count(&t), 0);

// Act — first ingest must materialise the tenant state.
let _ = cluster.ingest(&t, "hello world");

// Assert
assert_eq!(cluster.template_count(&t), 1);
}
}
1 change: 1 addition & 0 deletions crates/ourios-miner/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Drain-derived online template miner for Ourios.

pub mod cluster;
pub mod mask;
pub mod tokenize;
89 changes: 85 additions & 4 deletions crates/ourios-miner/tests/invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,96 @@ fn invariant_3_5_2_unknown_snapshot_version_triggers_wal_replay() {
/// Scenario §3.7.1 — Tenants' template trees never cross-pollinate.
/// See `docs/rfcs/0001-template-miner.md` §5.
#[test]
#[ignore = "RFC 0001 Red gate — implementation pending"]
fn invariant_3_7_1_tenant_trees_never_cross_pollinate() {
todo!("RFC 0001 §6.1, §6.2");
use ourios_core::config::MinerConfig;
use ourios_core::tenant::TenantId;
use ourios_miner::cluster::MinerCluster;

// Arrange — two tenants emitting *different* template
// shapes so the cross-pollination question is testable. A's
// lines exercise the "user <NUM> logged in" shape; B's
// lines exercise the "GET <PATH> <NUM>" shape (the path
// differs between B's two lines so each B line is its own
// template under exact-match templating, but neither
// matches anything in A's set).
let mut cluster = MinerCluster::new(MinerConfig::default());
let a = TenantId::new("tenant-a");
let b = TenantId::new("tenant-b");
let a_lines = ["user 42 logged in", "user 17 logged in"];
let b_lines = ["GET /home 200", "GET /api 200"];

// Act — interleave the two streams to make any tree
// sharing observable: a single shared store would
// accumulate all four shapes regardless of which tenant
// emitted which line.
for (la, lb) in a_lines.iter().zip(b_lines.iter()) {
cluster.ingest(&a, la);
cluster.ingest(&b, lb);
}

// Assert — A's templates contain only A-shaped tokens
// (`user`, `<NUM>`, `logged`, `in`); B's contain only
// B-shaped tokens (`GET`, the literal paths, `<NUM>`).
// Cross-pollination would mean either set contained tokens
// that originated in the other tenant's input.
let a_templates = cluster.templates_for(&a);
let b_templates = cluster.templates_for(&b);

let a_token_set: std::collections::HashSet<&str> = a_templates
.iter()
.flat_map(|(t, _)| t.iter().map(String::as_str))
.collect();
let b_token_set: std::collections::HashSet<&str> = b_templates
.iter()
.flat_map(|(t, _)| t.iter().map(String::as_str))
.collect();

assert!(
a_token_set.contains("user") && a_token_set.contains("logged"),
"A's tree must hold the A-shape tokens, got {a_token_set:?}",
);
assert!(
b_token_set.contains("GET"),
"B's tree must hold the B-shape tokens, got {b_token_set:?}",
);
assert!(
!a_token_set.contains("GET"),
"A's tree must NOT contain B-shape tokens (cross-pollination), got {a_token_set:?}",
);
assert!(
!b_token_set.contains("user") && !b_token_set.contains("logged"),
"B's tree must NOT contain A-shape tokens (cross-pollination), got {b_token_set:?}",
);
}

/// Scenario §3.7.2 — Same structural template in two tenants gets distinct `template_id`s.
/// See `docs/rfcs/0001-template-miner.md` §5.
#[test]
#[ignore = "RFC 0001 Red gate — implementation pending"]
fn invariant_3_7_2_same_template_two_tenants_distinct_template_ids() {
todo!("RFC 0001 §6.1");
use ourios_core::config::MinerConfig;
use ourios_core::tenant::TenantId;
use ourios_miner::cluster::MinerCluster;

// Arrange — two tenants emit the structurally identical
// line. After masking they produce the same token sequence
// (`user <NUM> logged in from <IP>`).
let mut cluster = MinerCluster::new(MinerConfig::default());
let a = TenantId::new("tenant-a");
let b = TenantId::new("tenant-b");
let line = "user 42 logged in from 10.0.0.1";

// Act — same line, different tenants.
let id_a = cluster.ingest(&a, line);
let id_b = cluster.ingest(&b, line);

// Assert — RFC 0001 §6.1's `template_id` allocator is
// cluster-wide unique (the id space is shared across tenants
// so the same `u64` value never refers to two different
// leaves), so even when two tenants ingest the same masked
// shape the second call pulls the *next* monotonic id rather
// than reusing the first tenant's id.
assert_ne!(
id_a, id_b,
"structurally identical templates must get distinct template_ids across tenants",
);
}
Loading