1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
// Copyright (c) 2025-2026 Adrian Robinson. Licensed under the AGPL-3.0.
// See LICENSE file in the project root for full license text.
//! Search API for SyncEngine
//!
//! Provides full-text search capabilities via RediSearch with SQL fallback.
//!
//! # Architecture
//!
//! ```text
//! search(index, query)
//! │
//! ├─→ Check SearchCache (merkle-validated)
//! │ │
//! │ └─→ Hit? Return cached keys
//! │
//! ├─→ FT.SEARCH Redis (fast)
//! │ │
//! │ └─→ Results? Return
//! │
//! └─→ Durable tier? SQL fallback
//! │
//! └─→ Cache results with merkle root
//! ```
use std::time::Instant;
use tracing::{debug, info};
use crate::metrics;
use crate::search::{
IndexManager, SearchIndex, SearchCache, SearchCacheStats,
Query, RediSearchTranslator, SqlTranslator, SqlParam,
};
use crate::sync_item::SyncItem;
use crate::storage::traits::StorageError;
use super::SyncEngine;
/// Search tier strategy
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SearchTier {
/// Redis only - no SQL fallback (for view: prefix ephemeral data)
RedisOnly,
/// Redis with SQL fallback (for crdt: prefix durable data)
#[default]
RedisWithSqlFallback,
}
/// Search result with metadata
#[derive(Debug, Clone)]
pub struct SearchResult {
/// Matching items
pub items: Vec<SyncItem>,
/// Source of results
pub source: SearchSource,
/// Whether results came from cache
pub cached: bool,
}
/// Where search results came from
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchSource {
/// Results from hot tier (RediSearch/Dragonfly)
Hot,
/// Results from cold tier (SQL/MySQL)
Cold,
/// Results from SearchCache
Cache,
/// No results found
Empty,
}
/// Search-related state for SyncEngine
#[derive(Default)]
pub struct SearchState {
/// Index manager
pub index_manager: IndexManager,
/// Search result cache
pub cache: SearchCache,
}
impl SyncEngine {
// ═══════════════════════════════════════════════════════════════════════════
// Search API
// ═══════════════════════════════════════════════════════════════════════════
/// Register a search index.
///
/// Creates the index in RediSearch using FT.CREATE. The index will
/// automatically index all JSON documents with matching key prefix.
///
/// # Example
///
/// ```rust,no_run
/// # use sync_engine::{SyncEngine, search::SearchIndex};
/// # async fn example(engine: &SyncEngine) -> Result<(), Box<dyn std::error::Error>> {
/// // Define index schema
/// let index = SearchIndex::new("users", "crdt:users:")
/// .text_sortable("name")
/// .text("email")
/// .numeric_sortable("age")
/// .tag("roles");
///
/// // Create in RediSearch
/// engine.create_search_index(index).await?;
/// # Ok(())
/// # }
/// ```
pub async fn create_search_index(&self, index: SearchIndex) -> Result<(), StorageError> {
let l2 = self.l2_store.as_ref().ok_or_else(|| {
StorageError::Connection("Redis not available for search index".into())
})?;
// Build FT.CREATE command with global redis prefix
let redis_prefix = self.config.read().redis_prefix.clone();
let args = index.to_ft_create_args_with_prefix(redis_prefix.as_deref());
debug!(index = %index.name, prefix = %index.prefix, redis_prefix = ?redis_prefix, "Creating search index");
// Execute FT.CREATE via raw Redis command
match l2.ft_create(&args).await {
Ok(()) => {
metrics::record_search_index_operation("create", true);
// Register in index manager
if let Some(ref search_state) = self.search_state {
search_state.write().index_manager.register(index);
}
info!(index = %args[0], "Search index created");
Ok(())
}
Err(e) => {
metrics::record_search_index_operation("create", false);
Err(e)
}
}
}
/// Drop a search index.
///
/// Removes the index from RediSearch. Does not delete the indexed documents.
pub async fn drop_search_index(&self, name: &str) -> Result<(), StorageError> {
let l2 = self.l2_store.as_ref().ok_or_else(|| {
StorageError::Connection("Redis not available".into())
})?;
let prefix = self.config.read().redis_prefix.clone().unwrap_or_default();
let index_name = format!("{}idx:{}", prefix, name);
match l2.ft_dropindex(&index_name).await {
Ok(()) => {
metrics::record_search_index_operation("drop", true);
info!(index = %index_name, "Search index dropped");
Ok(())
}
Err(e) => {
metrics::record_search_index_operation("drop", false);
Err(e)
}
}
}
/// Get the view prefix for a search index.
///
/// Returns the prefix used for view: keys in SQL (e.g., "view:test:user:").
/// This is needed for SQL search filtering to only search materialized views.
pub fn get_index_view_prefix(&self, index_name: &str) -> String {
let crdt_prefix = if let Some(ref search_state) = self.search_state {
search_state.read()
.index_manager
.get(index_name)
.map(|idx| idx.prefix.clone())
} else {
None
};
let crdt_prefix = crdt_prefix.unwrap_or_else(|| format!("crdt:{}:", index_name));
crdt_prefix.replace("crdt:", "view:")
}
/// Search for items using RediSearch query syntax.
///
/// Searches the specified index using FT.SEARCH. For durable data (crdt: prefix),
/// falls back to SQL if Redis returns no results.
///
/// # Arguments
///
/// * `index_name` - Name of the search index (without "idx:" prefix)
/// * `query` - Query AST built with `Query::` constructors
///
/// # Example
///
/// ```rust,no_run
/// # use sync_engine::{SyncEngine, search::Query};
/// # async fn example(engine: &SyncEngine) -> Result<(), Box<dyn std::error::Error>> {
/// // Simple field query
/// let results = engine.search("users", &Query::field_eq("name", "Alice")).await?;
///
///
/// // Complex query with AND/OR
/// let query = Query::field_eq("status", "active")
/// .and(Query::numeric_range("age", Some(25.0), Some(40.0)));
/// let results = engine.search("users", &query).await?;
///
/// for item in results.items {
/// println!("Found: {}", item.object_id);
/// }
/// # Ok(())
/// # }
/// ```
pub async fn search(
&self,
index_name: &str,
query: &Query,
) -> Result<SearchResult, StorageError> {
self.search_with_options(index_name, query, SearchTier::default(), 100).await
}
/// Search with explicit tier and limit options.
pub async fn search_with_options(
&self,
index_name: &str,
query: &Query,
tier: SearchTier,
limit: usize,
) -> Result<SearchResult, StorageError> {
let start = Instant::now();
// Get index info
let prefix = if let Some(ref search_state) = self.search_state {
search_state.read()
.index_manager
.get(index_name)
.map(|idx| idx.prefix.clone())
} else {
None
};
let prefix = prefix.unwrap_or_else(|| format!("crdt:{}:", index_name));
// Check cache first (if we have merkle root)
if let Some(ref search_state) = self.search_state {
if let Some(merkle_root) = self.get_merkle_root_for_prefix(&prefix).await {
let cached_keys = search_state.read().cache.get(&prefix, query, &merkle_root);
if let Some(keys) = cached_keys {
debug!(index = %index_name, count = keys.len(), "Search cache hit");
metrics::record_search_cache(true);
metrics::record_search_latency("cache", start.elapsed());
metrics::record_search_results(keys.len());
let items = self.fetch_items_by_keys(&keys).await?;
return Ok(SearchResult {
items,
source: SearchSource::Cache,
cached: true,
});
}
}
}
// Try RediSearch
let redis_start = Instant::now();
let redis_results = self.search_redis(index_name, query, limit).await;
match redis_results {
Ok(items) if !items.is_empty() => {
debug!(index = %index_name, count = items.len(), "RediSearch results");
metrics::record_search_query("redis", "success");
metrics::record_search_latency("redis", redis_start.elapsed());
metrics::record_search_results(items.len());
Ok(SearchResult {
items,
source: SearchSource::Hot,
cached: false,
})
}
Ok(_) | Err(_) => {
// Record Redis attempt (empty or error)
if redis_results.is_err() {
metrics::record_search_query("redis", "error");
} else {
metrics::record_search_query("redis", "empty");
}
// Empty or error - try SQL fallback for durable tier
if tier == SearchTier::RedisWithSqlFallback {
let sql_start = Instant::now();
// Use view: prefix for SQL search (views have the materialized data)
let view_prefix = prefix.replace("crdt:", "view:");
let sql_results = self.search_sql_with_prefix(query, &view_prefix, limit).await?;
let is_empty = sql_results.is_empty();
metrics::record_search_query("sql", "success");
metrics::record_search_latency("sql", sql_start.elapsed());
metrics::record_search_results(sql_results.len());
// Cache results if we have merkle
if !is_empty {
if let Some(ref search_state) = self.search_state {
if let Some(merkle_root) = self.get_merkle_root_for_prefix(&prefix).await {
let keys: Vec<String> = sql_results.iter()
.map(|item| item.object_id.clone())
.collect();
search_state.write().cache.insert(&prefix, query, merkle_root, keys);
}
}
}
Ok(SearchResult {
items: sql_results,
source: if is_empty { SearchSource::Empty } else { SearchSource::Cold },
cached: false,
})
} else {
// RedisOnly tier - return empty
metrics::record_search_results(0);
Ok(SearchResult {
items: vec![],
source: SearchSource::Empty,
cached: false,
})
}
}
}
}
/// Search using raw RediSearch query string (Redis-only, no SQL fallback).
///
/// Use this when you need the full power of RediSearch syntax
/// without the Query AST. This is an **advanced API** with caveats:
///
/// - **No SQL fallback**: If Redis is unavailable, this will fail
/// - **No search cache**: Results are not cached via the merkle system
/// - **Manual paths**: You must use `$.payload.{field}` paths in your query
/// - **No translation**: The query string is passed directly to FT.SEARCH
///
/// Prefer `search()` or `search_with_options()` for most use cases.
///
/// # Example
/// ```ignore
/// // Raw RediSearch query with explicit payload paths
/// let results = engine.search_raw(
/// "users",
/// "@name:(Alice Smith) @age:[25 35]",
/// 100
/// ).await?;
/// ```
pub async fn search_raw(
&self,
index_name: &str,
query_str: &str,
limit: usize,
) -> Result<Vec<SyncItem>, StorageError> {
let l2 = self.l2_store.as_ref().ok_or_else(|| {
StorageError::Connection("Redis not available for raw search".into())
})?;
let prefix = self.config.read().redis_prefix.clone().unwrap_or_default();
let index = format!("{}idx:{}", prefix, index_name);
metrics::record_search_query("redis_raw", "attempt");
let start = std::time::Instant::now();
let keys = l2.ft_search(&index, query_str, limit).await?;
let items = self.fetch_items_by_keys(&keys).await?;
metrics::record_search_query("redis_raw", "success");
metrics::record_search_latency("redis_raw", start.elapsed());
metrics::record_search_results(items.len());
Ok(items)
}
/// Direct SQL search (bypasses Redis, SQL-only).
///
/// Queries the SQL archive directly using JSON_EXTRACT.
/// This is an **advanced API** with specific use cases:
///
/// - **No Redis**: Bypasses L2 cache entirely
/// - **No caching**: Results are not cached via the merkle system
/// - **Ground truth**: Queries the durable SQL archive
///
/// Useful for:
/// - Analytics queries that need complete data
/// - When Redis is unavailable or not trusted
/// - Bulk operations on archived data
///
/// Prefer `search()` or `search_with_options()` for most use cases.
pub async fn search_sql(
&self,
query: &Query,
limit: usize,
) -> Result<Vec<SyncItem>, StorageError> {
self.search_sql_with_prefix(query, "", limit).await
}
/// Search SQL with a key prefix filter.
///
/// Only items whose `id` starts with the given prefix will be searched.
/// Uses the schema registry to route to the correct table for partitioned schemas.
pub async fn search_sql_with_prefix(
&self,
query: &Query,
key_prefix: &str,
limit: usize,
) -> Result<Vec<SyncItem>, StorageError> {
let sql_store = self.sql_store.as_ref().ok_or_else(|| {
StorageError::Connection("SQL not available".into())
})?;
metrics::record_search_query("sql_direct", "attempt");
let start = std::time::Instant::now();
let sql_query = SqlTranslator::translate(query, "payload");
// Determine which table to search based on prefix
// For "view:users:" prefix, look up in schema registry
let table = self.schema_registry.table_for_key(key_prefix);
// Combine prefix filter with query clause
let (full_clause, full_params) = if key_prefix.is_empty() {
(sql_query.clause.clone(), sql_query.params.clone())
} else {
// Add prefix filter: id LIKE 'prefix%' AND (original query)
let mut params = vec![SqlParam::Text(format!("{}%", key_prefix))];
params.extend(sql_query.params.clone());
(format!("id LIKE ? AND ({})", sql_query.clause), params)
};
debug!(clause = %full_clause, prefix = %key_prefix, table = %table, "SQL search");
let results = sql_store.search_in_table(table, &full_clause, &full_params, limit).await?;
metrics::record_search_query("sql_direct", "success");
metrics::record_search_latency("sql_direct", start.elapsed());
metrics::record_search_results(results.len());
Ok(results)
}
/// Count items matching a query in SQL (fast COUNT(*) without fetching data).
///
/// Use this for exhaustiveness checks: compare Redis result count with SQL total.
/// This is much faster than fetching all results just to count them.
///
/// # Example
/// ```ignore
/// let redis_results = engine.search("users", &query).await?;
/// let sql_count = engine.search_count_sql(&query).await?;
/// let exhaustive = redis_results.items.len() as u64 == sql_count;
/// ```
pub async fn search_count_sql(&self, query: &Query) -> Result<u64, StorageError> {
self.search_count_sql_with_prefix(query, "").await
}
/// Count items matching a query in SQL with a key prefix filter.
/// Uses the schema registry to route to the correct table for partitioned schemas.
pub async fn search_count_sql_with_prefix(&self, query: &Query, key_prefix: &str) -> Result<u64, StorageError> {
let sql_store = self.sql_store.as_ref().ok_or_else(|| {
StorageError::Connection("SQL not available".into())
})?;
let sql_query = SqlTranslator::translate(query, "payload");
// Determine which table to search based on prefix
let table = self.schema_registry.table_for_key(key_prefix);
// Combine prefix filter with query clause
let (full_clause, full_params) = if key_prefix.is_empty() {
(sql_query.clause.clone(), sql_query.params.clone())
} else {
let mut params = vec![SqlParam::Text(format!("{}%", key_prefix))];
params.extend(sql_query.params.clone());
(format!("id LIKE ? AND ({})", sql_query.clause), params)
};
debug!(clause = %full_clause, prefix = %key_prefix, table = %table, "SQL count");
sql_store.count_where_in_table(table, &full_clause, &full_params).await
}
/// Get search cache statistics.
pub fn search_cache_stats(&self) -> Option<SearchCacheStats> {
self.search_state.as_ref().map(|s| s.read().cache.stats())
}
// ═══════════════════════════════════════════════════════════════════════════
// Internal helpers
// ═══════════════════════════════════════════════════════════════════════════
/// Fetch items by keys, filtering out None results
async fn fetch_items_by_keys(&self, keys: &[String]) -> Result<Vec<SyncItem>, StorageError> {
if keys.is_empty() {
return Ok(vec![]);
}
let key_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();
let results = self.get_many(&key_refs).await;
Ok(results.into_iter().flatten().collect())
}
async fn search_redis(
&self,
index_name: &str,
query: &Query,
limit: usize,
) -> Result<Vec<SyncItem>, StorageError> {
let l2 = self.l2_store.as_ref().ok_or_else(|| {
StorageError::Connection("Redis not available".into())
})?;
let prefix = self.config.read().redis_prefix.clone().unwrap_or_default();
let index = format!("{}idx:{}", prefix, index_name);
// Translate query with potential vector parameters
let translated = RediSearchTranslator::translate_with_params(query);
debug!(index = %index, query = %translated.query, has_params = %translated.has_params(), "FT.SEARCH");
let keys = if translated.has_params() {
// Vector search with binary parameters
l2.ft_search_with_params(&index, &translated.query, &translated.params, limit).await?
} else {
// Regular search
l2.ft_search(&index, &translated.query, limit).await?
};
self.fetch_items_by_keys(&keys).await
}
async fn get_merkle_root_for_prefix(&self, prefix: &str) -> Option<Vec<u8>> {
// Extract the path segment from prefix (e.g., "crdt:users:" -> "crdt:users")
let path = prefix.trim_end_matches(':');
if let Some(ref merkle_cache) = self.merkle_cache {
if let Ok(Some(node)) = merkle_cache.get_node(path).await {
return Some(node.hash.to_vec());
}
}
None
}
}