Search Architecture
This document describes the internal architecture of OctoFHIR’s search parameter system, including how search parameters are loaded, registered, and used during search operations.
Architecture Overview
Section titled “Architecture Overview”┌─────────────────────────────────────────────────────────────────┐│ FHIR REST API Layer ││ POST /SearchParameter → Create & Auto-Register ││ GET /Patient?name=... → Query using Registry │└──────────────────┬──────────────────────────────────────────────┘ │ ┌──────────┴──────────┐ ▼ ▼┌───────────────────┐ ┌────────────────────┐│ Postprocess Hook │ │ Search Handler ││ - Validate FHIRPath│ │ - Parse Query ││ - Upsert Registry │ │ - Get Config ││ - Clear Cache │ │ - Execute Search │└─────────┬─────────┘ └──────────┬─────────┘ │ │ ▼ ▼┌─────────────────────────────────────────────┐│ ReloadableSearchConfig (Arc-Swap) ││ ┌───────────────────────────────────────┐ ││ │ SearchConfig (Lock-Free Reads) │ ││ │ ┌─────────────────────────────────┐ │ ││ │ │ SearchParameterRegistry │ │ ││ │ │ - by_resource (DashMap) │ │ ││ │ │ - by_url (DashMap) │ │ ││ │ │ - common (DashMap) │ │ ││ │ └─────────────────────────────────┘ │ ││ │ ┌─────────────────────────────────┐ │ ││ │ │ QueryCache (Optional) │ │ ││ │ └─────────────────────────────────┘ │ ││ └───────────────────────────────────────┘ │└─────────────────────────────────────────────┘ │ ▼ ┌──────────────────┐ │ Canonical Manager│ │ (PostgreSQL) │ └──────────────────┘Core Components
Section titled “Core Components”1. SearchParameterRegistry
Section titled “1. SearchParameterRegistry”Thread-safe registry using DashMap for lock-free concurrent access.
Location: crates/octofhir-search/src/registry.rs
Data Structures:
pub struct SearchParameterRegistry { /// Parameters indexed by (resource_type, code) by_resource: DashMap<(String, String), Arc<SearchParameter>>, /// All parameters by canonical URL by_url: DashMap<String, Arc<SearchParameter>>, /// Common parameters (base = "Resource" or "DomainResource") common: DashMap<String, Arc<SearchParameter>>,}Key Methods:
register(&self, param: SearchParameter)- Register a new parameterupsert(&self, param: SearchParameter)- Update or insert (alias for register)remove_by_url(&self, url: &str) -> bool- Remove by canonical URLget(&self, resource_type: &str, code: &str) -> Option<Arc<SearchParameter>>- Lookup parameter
Why DashMap?
- Lock-free concurrent HashMap
- Multiple readers can access simultaneously
- Writers don’t block readers
- O(1) lookup performance
2. ReloadableSearchConfig
Section titled “2. ReloadableSearchConfig”Wrapper providing lock-free configuration access using atomic pointer swapping.
Location: crates/octofhir-search/src/reloadable.rs
Implementation:
pub struct ReloadableSearchConfig { /// Inner config with atomic pointer swap (lock-free reads!) inner: Arc<ArcSwap<SearchConfig>>, options: Arc<RwLock<SearchOptions>>, cache: Option<Arc<QueryCache>>,}Key Operations:
Get Config (Lock-Free Read):
pub fn config(&self) -> Arc<SearchConfig> { self.inner.load_full() // Single atomic load, ~1-5 nanoseconds}Reload Registry (Atomic Swap):
pub async fn reload_registry(&self, canonical_manager: &CanonicalManager) -> Result<(), LoaderError>{ // 1. Load new registry (background, doesn't block readers) let new_registry = Arc::new(load_search_parameters(canonical_manager).await?);
// 2. Create new config let current = self.inner.load_full(); let new_config = SearchConfig { default_count: current.default_count, max_count: current.max_count, registry: new_registry, cache: self.cache.clone(), };
// 3. Atomic swap (old readers keep old config, new readers get new config) self.inner.store(Arc::new(new_config));
// 4. Clear query cache if let Some(cache) = &self.cache { cache.clear(); }
Ok(())}Why Arc-Swap?
- Zero lock contention - Readers never wait for writers
- Atomic updates - Pointer swap is atomic (single CPU instruction)
- Graceful transition - Old readers finish with old config
- Automatic cleanup - Old config freed when last reader releases it
3. Search Parameter Loading
Section titled “3. Search Parameter Loading”Location: crates/octofhir-search/src/loader.rs
Loading Process:
-
Register Common Parameters (built-in)
_id,_lastUpdated,_tag,_profile,_security,_source- Apply to all resource types
-
Query Canonical Manager
- Load all SearchParameter resources from PostgreSQL
- High limit (10,000) to get all parameters in one query
-
Parse and Validate
- Extract required fields (code, url, type, base)
- Parse FHIRPath expression
- Build internal SearchParameter struct
-
Register in Registry
- Index by URL
- Index by (resource_type, code) for each base
- Mark as common if base includes “Resource”
Performance:
- Initial load: ~100-200ms for ~1000 parameters
- Incremental update: ~100-500 nanoseconds per parameter
4. Postprocessing Hook
Section titled “4. Postprocessing Hook”Location: crates/octofhir-server/src/handlers.rs
Automatically called after successful create/update/delete operations.
Implementation:
async fn postprocess_resource( resource_type: &str, _resource_id: &str, payload: &Value, state: &AppState,) -> Result<(), ApiError> { if resource_type == "SearchParameter" { // 1. Validate FHIRPath expression syntax if let Some(expression) = payload.get("expression").and_then(|v| v.as_str()) { validate_fhirpath_expression(expression)?; }
// 2. Parse SearchParameter resource match octofhir_search::parse_search_parameter(payload) { Ok(param) => { // 3. Get current config snapshot let config = state.search_config.config();
// 4. Incrementally update registry (thread-safe) config.registry.upsert(param);
// 5. Clear query cache to prevent stale results if let Some(cache) = &config.cache { cache.clear(); }
tracing::info!( code = payload.get("code").and_then(|v| v.as_str()), "Search parameter registered incrementally" ); } Err(e) => { tracing::error!(error = %e, "Failed to parse SearchParameter"); } } }
Ok(())}FHIRPath Validation:
fn validate_fhirpath_expression(expression: &str) -> Result<(), ApiError> { use octofhir_fhirpath::parse;
let result = parse(expression); if !result.success { let error_msg = result.error_message .unwrap_or_else(|| "Unknown parse error".to_string()); return Err(ApiError::bad_request(format!( "Invalid FHIRPath expression: {}", error_msg ))); }
Ok(())}Validation Scope:
- ✅ Syntax correctness (parentheses, operators, function names)
- ✅ Basic structure (paths, predicates, function calls)
- ❌ Semantic correctness (doesn’t check against resource schema)
- ❌ Type checking (doesn’t validate return types)
Performance Characteristics
Section titled “Performance Characteristics”Lock-Free Architecture
Section titled “Lock-Free Architecture”Traditional Approach (RwLock):
┌─────────────────────────────────────────────┐│ RwLock<SearchConfig> ││ ││ Readers: [Wait] [Wait] [Wait] [Wait] ││ Writer: [LOCK - BLOCKING ALL READERS] ││ ││ Problem: All searches blocked during ││ registry reload (1-2 seconds) │└─────────────────────────────────────────────┘OctoFHIR Approach (Arc-Swap):
┌─────────────────────────────────────────────┐│ ArcSwap<SearchConfig> ││ ││ Readers: [Read Old] [Read Old] [Read New] ││ Writer: [Create New] → [Atomic Swap] ││ ││ Benefit: Zero blocking, seamless ││ transition between configs │└─────────────────────────────────────────────┘Performance Metrics
Section titled “Performance Metrics”| Operation | Time | Impact on Searches |
|---|---|---|
| Registry lookup (read) | 1-5 ns | None (lock-free) |
| Incremental update (upsert) | 100-500 ns | None (lock-free) |
| Full registry reload | 100-200 ms | None (atomic swap) |
| Query cache lookup | 10-50 ns | None (concurrent) |
Memory Usage
Section titled “Memory Usage”Per SearchParameter:
- SearchParameter struct: ~200 bytes
- Arc overhead: 16 bytes
- DashMap entry overhead: ~40 bytes
- Total: ~256 bytes per parameter
Full Registry (1000 parameters):
- Parameters: ~256 KB
- Three indices (by_resource, by_url, common): ~768 KB
- Total: ~1 MB
Config Snapshot:
Arc<SearchConfig>: 8 bytes (pointer)- Reference counting: atomic operations
- Zero copy on read
Concurrency Model
Section titled “Concurrency Model”Read Operations (Search Queries)
Section titled “Read Operations (Search Queries)”// Multiple threads can read simultaneouslylet config = state.search_config.config(); // Arc<SearchConfig>let param = config.registry.get("Patient", "name"); // Option<Arc<SearchParameter>>
// All operations are lock-free:// 1. config() - single atomic load// 2. registry.get() - DashMap concurrent read// 3. No blocking, no waitingWrite Operations (SearchParameter CRUD)
Section titled “Write Operations (SearchParameter CRUD)”// Incremental update (after POST/PUT SearchParameter)let config = state.search_config.config();config.registry.upsert(new_param); // DashMap concurrent insert
// Full reload (after package installation)state.search_config.reload_registry(&canonical_manager).await?;// 1. Loads new registry in background// 2. Swaps pointer atomically// 3. Old config stays alive until readers release itThread Safety Guarantees
Section titled “Thread Safety Guarantees”- No Data Races - All data structures are thread-safe
- No Deadlocks - No locks used in read path
- No Starvation - Readers never wait for writers
- Eventual Consistency - All readers eventually see updates
Query Cache
Section titled “Query Cache”Location: crates/octofhir-search/src/query_cache.rs
Purpose: Cache parsed search queries to avoid re-parsing identical queries.
Implementation:
pub struct QueryCache { cache: Arc<DashMap<QueryCacheKey, QueryCacheEntry>>, capacity: usize,}
struct QueryCacheKey { resource_type: String, query_string: String,}Cache Invalidation:
- Cleared when SearchParameter is created/updated/deleted
- Cleared when registry is reloaded
- Prevents stale query results
Performance:
- Cache hit: ~10-50 nanoseconds
- Cache miss: ~1-10 microseconds (parse + cache)
- Hit rate: Typically 60-90% in production
Multi-Instance Behavior
Section titled “Multi-Instance Behavior”Challenge
Section titled “Challenge”Multiple OctoFHIR instances sharing the same PostgreSQL database:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐│ Instance A │ │ Instance B │ │ Instance C ││ │ │ │ │ ││ Registry A │ │ Registry B │ │ Registry C │└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ └───────────────────┴───────────────────┘ │ ┌─────────▼──────────┐ │ PostgreSQL │ │ (Canonical Mgr) │ └────────────────────┘Current Behavior
Section titled “Current Behavior”-
SearchParameter Created on Instance A:
- Stored in PostgreSQL (shared)
- Registered in Instance A’s registry (local)
- Instance B and C don’t know about it yet
-
Instance B/C Eventually Receive Update:
- When they reload from canonical manager
- Manual reload trigger
- Server restart
Recommendations
Section titled “Recommendations”For Development/Single Instance:
- Use REST API to create SearchParameters
- Changes are immediate on that instance
For Production/Multi-Instance:
- Use FHIR packages for SearchParameters
- Install packages on all instances
- Ensures consistency across cluster
Future Enhancement:
┌──────────────┐ ┌──────────────┐│ Instance A │ ◄─────► │ Redis ││ POST /SP │ Pub/Sub │ (Message Bus)│└──────────────┘ └──────┬───────┘ │ ┌───────────┴───────────┐ │ │ ┌───────▼──────┐ ┌───────▼──────┐ │ Instance B │ │ Instance C │ │ (Reload) │ │ (Reload) │ └──────────────┘ └──────────────┘Configuration
Section titled “Configuration”Config File: octofhir.toml
[search]default_count = 10 # Default result countmax_count = 100 # Maximum allowed countcache_capacity = 1000 # Query cache size (0 = disabled)Environment Variables:
OCTOFHIR__SEARCH__DEFAULT_COUNT=20OCTOFHIR__SEARCH__MAX_COUNT=500OCTOFHIR__SEARCH__CACHE_CAPACITY=5000Rust Configuration:
use octofhir_search::SearchOptions;
let options = SearchOptions { default_count: 10, max_count: 100, cache_capacity: 1000,};
let config = ReloadableSearchConfig::new(&canonical_manager, options).await?;Debugging and Monitoring
Section titled “Debugging and Monitoring”Logging
Section titled “Logging”Enable debug logging to see search parameter operations:
RUST_LOG=octofhir_search=debug cargo runLog Examples:
DEBUG octofhir_search::loader: Loaded search parameter code=name bases=["Patient"]INFO octofhir_search::loader: Loaded search parameters loaded=1247 skipped=3 total=1247INFO octofhir_server::handlers: Search parameter registered incrementally code="custom-field"DEBUG octofhir_search::reloadable: Cleared query cache after registry reloadMetrics
Section titled “Metrics”Track performance metrics:
// Registry sizelet param_count = state.search_config.config().registry.len();
// Cache statistics (if caching enabled)if let Some(stats) = state.search_config.cache_stats() { println!("Cache hits: {}", stats.hits); println!("Cache misses: {}", stats.misses); println!("Hit rate: {:.2}%", stats.hit_rate() * 100.0);}Common Issues
Section titled “Common Issues”High memory usage:
- Check registry size:
registry.len() - Review query cache capacity
- Monitor for memory leaks in cache
Slow search queries:
- Enable query cache if disabled
- Check for complex FHIRPath expressions
- Review database indices
Inconsistent results across instances:
- Verify all instances use same FHIR packages
- Check canonical manager synchronization
- Consider Redis pub/sub for real-time updates
Best Practices
Section titled “Best Practices”For Developers
Section titled “For Developers”- Use Lock-Free APIs - Always call
config()to get snapshot, never hold locks - Incremental Updates - Prefer
upsert()over full reload when possible - Clear Caches - Always clear query cache after registry changes
- Atomic Operations - Use atomic swap for config updates
For Production
Section titled “For Production”- Package-Based Parameters - Use FHIR packages for production SearchParameters
- Monitor Cache Hit Rate - Target >70% hit rate for optimal performance
- Size Query Cache Appropriately - 1000-5000 entries typical for production
- Log Registry Changes - Enable INFO logging to track parameter updates
For Testing
Section titled “For Testing”- Use Test Helpers -
ReloadableSearchConfig::with_registry()for unit tests - Mock Canonical Manager - Avoid database dependencies in tests
- Verify Thread Safety - Test concurrent reads and writes
- Check Memory Cleanup - Ensure old configs are freed
Future Enhancements
Section titled “Future Enhancements”Planned Features
Section titled “Planned Features”-
Redis Pub/Sub Integration
- Broadcast registry updates across instances
- Real-time synchronization in multi-instance deployments
-
Selective Cache Invalidation
- Only clear cached queries affected by parameter change
- Preserve unrelated cached queries
-
Parameter Versioning
- Support multiple versions of same parameter
- Gradual migration between versions
-
Performance Monitoring
- Built-in metrics endpoint
- Prometheus integration
- Query performance tracking
Research Topics
Section titled “Research Topics”-
Semantic FHIRPath Validation
- Validate expressions against resource schemas
- Type checking at registration time
- Prevent runtime errors
-
Distributed Registry
- Shared registry across all instances
- Consistent reads with local caching
- Eventually consistent writes
-
Parameter Optimization
- Analyze query patterns
- Suggest missing indices
- Identify unused parameters