Skip to content

Commit 25b3898

Browse files
rahulbswclaude
andcommitted
feat: MM2 features, string/cache transforms, hardened error handling
## New features ### MirrorMaker 2 parity - Topic regex subscription: `input: "^payments.*"` subscribes to all matching topics and picks up newly created ones automatically (rdkafka native support) - Output topic templates: `output: "mirror.{source_topic}"` resolves per-message from the envelope source topic with lazy partition-count caching ### Cache transforms (CACHE_PUT / CACHE_LOOKUP) - `SyncLookupCache` / `SyncCacheManager` — sync Moka cache shared across the transform pipeline; named stores auto-created on first reference - `CACHE_PUT:/key,store[,/valuePath]` — write message (or a field) to a named store; message passes through unchanged - `CACHE_LOOKUP:/key,store,field|MERGE` — enrich message from cache; pass through on miss or missing key ### String transforms (STRING:*) - UPPER, LOWER, TRIM, TRIM_START, TRIM_END, LENGTH, SUBSTRING, REPLACE, REPLACE_ALL, REGEX_REPLACE, SPLIT, CONCAT (12 operations) - All accept an optional `,outputField` to write result without touching original ### Single-destination transform support - New `transform: <expr>` top-level config field for single-destination mode - Full DSL (including CACHE_*) available without switching to routing mode ## Error handling hardening ### Fail-soft on data — all transforms pass through on missing/null fields - Missing field, null value, or wrong type for the operation → message passes through unchanged with a debug log; pipeline never halted for data issues - Applies to: JsonPath, ArrayMap, Arithmetic, Hash, String, Concat, CacheLookup, CachePut transforms - Config errors (invalid regex, empty `from`, empty CONCAT parts) still fail at parse time ### Additional fixes - `resolve_topic`: template + no source topic → error (was silent `mirror.unknown`) - `get_or_fetch_partitions`: blocking `fetch_metadata` wrapped in `block_in_place` - `partition_cache` mutex uses `unwrap_or_else(|e| e.into_inner())` for poison recovery - `flush()`: errors now propagate via `MirrorMakerError::Kafka` (was `let _ = ...`) - `flush_all`: attempts all sinks, collects errors, never abandons remaining sinks - `parse_input_topics`: warns on apparent regex patterns without `^` prefix - `STRING:REPLACE`: empty `from` rejected at parse time (prevents per-char insertion) - `STRING:CONCAT`: empty parts rejected at parse time (trailing/double commas) - `STRING:CONCAT`: empty `outputField` rejected at parse time - `routing` + `transform` both set: `warn!` that top-level transform is ignored ## README - Comprehensive comparison vs MirrorMaker 1 and MirrorMaker 2 (feature matrix, performance table, when-to-choose guidance) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 4231cac commit 25b3898

10 files changed

Lines changed: 1924 additions & 439 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ digest = "0.10"
5353
hex = "0.4"
5454

5555
# Caching
56-
moka = { version = "0.12", features = ["future"] }
56+
moka = { version = "0.12", features = ["future", "sync"] }
5757
dashmap = "6.0" # Concurrent HashMap
5858
redis = { version = "0.24", features = ["tokio-comp", "connection-manager"], optional = true }
5959

README.md

Lines changed: 260 additions & 219 deletions
Large diffs are not rendered by default.

src/cache.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,112 @@ impl Default for CacheManager {
186186
}
187187
}
188188

189+
// ============================================================================
190+
// Sync cache — used by transform/filter pipeline (which is synchronous)
191+
// ============================================================================
192+
193+
/// Synchronous lookup cache backed by `moka::sync::Cache`.
194+
///
195+
/// Used by `CacheLookupTransform` and `CachePutTransform` so they can
196+
/// implement the synchronous `Transform` trait without blocking an async thread.
197+
pub struct SyncLookupCache {
198+
cache: moka::sync::Cache<String, Value>,
199+
}
200+
201+
impl SyncLookupCache {
202+
pub fn new(config: CacheConfig) -> Self {
203+
let mut builder = moka::sync::Cache::builder()
204+
.max_capacity(config.max_capacity);
205+
206+
if let Some(ttl) = config.ttl_seconds {
207+
builder = builder.time_to_live(Duration::from_secs(ttl));
208+
}
209+
if let Some(tti) = config.tti_seconds {
210+
builder = builder.time_to_idle(Duration::from_secs(tti));
211+
}
212+
213+
info!(
214+
"Created sync lookup cache: max_capacity={}, ttl={:?}s, tti={:?}s",
215+
config.max_capacity, config.ttl_seconds, config.tti_seconds
216+
);
217+
218+
Self { cache: builder.build() }
219+
}
220+
221+
/// Look up a value by key. Returns `None` on cache miss.
222+
pub fn get(&self, key: &str) -> Option<Value> {
223+
let result = self.cache.get(key);
224+
if result.is_some() {
225+
debug!("Cache hit: {}", key);
226+
} else {
227+
debug!("Cache miss: {}", key);
228+
}
229+
result
230+
}
231+
232+
/// Insert a value into the cache.
233+
pub fn put(&self, key: String, value: Value) {
234+
debug!("Cache put: {}", key);
235+
self.cache.insert(key, value);
236+
}
237+
238+
/// Remove a key from the cache.
239+
pub fn remove(&self, key: &str) {
240+
self.cache.invalidate(key);
241+
}
242+
243+
/// Returns true if the key is present.
244+
pub fn contains_key(&self, key: &str) -> bool {
245+
self.cache.contains_key(key)
246+
}
247+
248+
pub fn entry_count(&self) -> u64 {
249+
self.cache.entry_count()
250+
}
251+
}
252+
253+
/// Named store of `SyncLookupCache` instances.
254+
///
255+
/// Passed to the transform parser so `CACHE_LOOKUP` and `CACHE_PUT`
256+
/// expressions can reference caches by name. Caches are created on
257+
/// first use with default settings (10k entries, 1h TTL).
258+
pub struct SyncCacheManager {
259+
caches: dashmap::DashMap<String, Arc<SyncLookupCache>>,
260+
}
261+
262+
impl SyncCacheManager {
263+
pub fn new() -> Self {
264+
Self { caches: dashmap::DashMap::new() }
265+
}
266+
267+
/// Get an existing cache by name, or create it with default config.
268+
pub fn get_or_create(&self, name: &str) -> Arc<SyncLookupCache> {
269+
self.caches
270+
.entry(name.to_string())
271+
.or_insert_with(|| {
272+
info!("Creating new sync cache store: '{}'", name);
273+
Arc::new(SyncLookupCache::new(CacheConfig::default()))
274+
})
275+
.clone()
276+
}
277+
278+
/// Get an existing cache by name. Returns `None` if it doesn't exist.
279+
pub fn get(&self, name: &str) -> Option<Arc<SyncLookupCache>> {
280+
self.caches.get(name).map(|e| e.clone())
281+
}
282+
283+
/// List all named stores.
284+
pub fn store_names(&self) -> Vec<String> {
285+
self.caches.iter().map(|e| e.key().clone()).collect()
286+
}
287+
}
288+
289+
impl Default for SyncCacheManager {
290+
fn default() -> Self {
291+
Self::new()
292+
}
293+
}
294+
189295
#[cfg(test)]
190296
mod tests {
191297
use super::*;

src/config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ pub struct MirrorMakerConfig {
3434
/// Multi-destination routing configuration
3535
pub routing: Option<RoutingConfig>,
3636

37+
/// Value transform expression for single-destination mode.
38+
///
39+
/// Supports the full transform DSL including `STRING:`, `CACHE_LOOKUP:`,
40+
/// `CACHE_PUT:`, `HASH:`, `CONSTRUCT:`, `ARITHMETIC:`, etc.
41+
/// Ignored when `routing` is set.
42+
#[serde(default)]
43+
pub transform: Option<String>,
44+
3745
/// Consumer properties
3846
#[serde(default)]
3947
pub consumer_properties: HashMap<String, String>,

0 commit comments

Comments
 (0)