324 lines
10 KiB
Markdown
324 lines
10 KiB
Markdown
# Task 02: Query Executor Degradation Branches
|
|
|
|
## Delivers
|
|
|
|
Wire `DegradationLevel` into `RetrieveExecutor` and `SearchExecutor` so that each stage of the pipeline respects the current degradation level. Candidate generation, signal aggregation, and diversity enforcement all have degradation-aware code paths.
|
|
|
|
## Complexity: M
|
|
|
|
## Dependencies
|
|
|
|
- task-01 (`DegradationLevel`, `LoadDetector`, `InFlightGuard`)
|
|
- m2p5 `RetrieveExecutor` (6-stage pipeline)
|
|
- m5p3 `SearchExecutor` (8-stage pipeline)
|
|
|
|
## Technical Design
|
|
|
|
### 1. Thread DegradationLevel through executor construction
|
|
|
|
Both executors are constructed per-query in `TidalDb::retrieve()` and `TidalDb::search()`. The degradation level is passed as a field on the executor.
|
|
|
|
```rust
|
|
// In tidal/src/query/executor/mod.rs, add to RetrieveExecutor:
|
|
pub struct RetrieveExecutor<'a> {
|
|
// ... existing fields ...
|
|
degradation_level: crate::load::DegradationLevel,
|
|
}
|
|
|
|
impl<'a> RetrieveExecutor<'a> {
|
|
// Add builder method:
|
|
#[must_use]
|
|
pub const fn with_degradation_level(
|
|
mut self,
|
|
level: crate::load::DegradationLevel,
|
|
) -> Self {
|
|
self.degradation_level = level;
|
|
self
|
|
}
|
|
}
|
|
```
|
|
|
|
```rust
|
|
// In tidal/src/query/search/executor.rs, add to SearchExecutor:
|
|
pub struct SearchExecutor<'a> {
|
|
// ... existing fields ...
|
|
degradation_level: crate::load::DegradationLevel,
|
|
}
|
|
|
|
impl<'a> SearchExecutor<'a> {
|
|
#[must_use]
|
|
pub const fn with_degradation_level(
|
|
mut self,
|
|
level: crate::load::DegradationLevel,
|
|
) -> Self {
|
|
self.degradation_level = level;
|
|
self
|
|
}
|
|
}
|
|
```
|
|
|
|
Default to `DegradationLevel::Full` in `new()` so that all existing tests pass without modification.
|
|
|
|
### 2. Wire LoadDetector into TidalDb::retrieve() and TidalDb::search()
|
|
|
|
In `tidal/src/db/query_ops.rs` (or wherever `retrieve()` / `search()` are implemented):
|
|
|
|
```rust
|
|
impl TidalDb {
|
|
pub fn retrieve(&self, query: &Retrieve) -> crate::Result<Results> {
|
|
// Enter the load detector. The guard decrements on drop (method return).
|
|
let (degradation_level, _guard) = self.load_detector.enter();
|
|
|
|
tracing::debug!(
|
|
degradation = %degradation_level,
|
|
in_flight = self.load_detector.in_flight(),
|
|
"query entry"
|
|
);
|
|
|
|
// Build executor as before, then wire degradation level:
|
|
let executor = RetrieveExecutor::new(/* ... */)
|
|
.with_degradation_level(degradation_level);
|
|
// ... rest of the method
|
|
}
|
|
}
|
|
```
|
|
|
|
Same pattern for `search()`.
|
|
|
|
### 3. Stage 1 -- ReducedCandidates: ANN top_k and BM25 limit
|
|
|
|
In `SearchExecutor::execute()`, the ANN `top_k` is currently computed as:
|
|
|
|
```rust
|
|
let k = (query.limit as usize * 20).max(200);
|
|
```
|
|
|
|
Under `ReducedCandidates`, reduce the over-fetch factor:
|
|
|
|
```rust
|
|
let k = if self.degradation_level.reduces_candidates() {
|
|
// Reduced: cap at 200 regardless of query limit.
|
|
// This cuts ANN search work by ~60% for typical limit=20 queries.
|
|
(query.limit as usize * 10).max(100).min(200)
|
|
} else {
|
|
(query.limit as usize * 20).max(200)
|
|
};
|
|
```
|
|
|
|
For BM25, the `AllScoresCollector` currently returns all matches. Under degradation, post-truncate the BM25 results:
|
|
|
|
```rust
|
|
if self.degradation_level.reduces_candidates() {
|
|
// Cap BM25 candidates at half the normal budget to reduce
|
|
// downstream scoring work. Truncation preserves BM25 rank order
|
|
// so the top results are not lost, only the long tail.
|
|
let bm25_cap = (query.limit as usize * 10).max(100);
|
|
bm25_results.truncate(bm25_cap);
|
|
}
|
|
```
|
|
|
|
For `RetrieveExecutor`, the Scan candidate strategy currently caps at the universe size. Under degradation, reduce the scan cap:
|
|
|
|
```rust
|
|
// In candidate_gen::scan_candidates, or at the call site:
|
|
let scan_cap = if degradation_level.reduces_candidates() {
|
|
query.limit * 5 // reduced from default 10x over-fetch
|
|
} else {
|
|
query.limit * 10
|
|
};
|
|
```
|
|
|
|
### 4. Stage 3 -- CoarseAggregates: signal read fallback
|
|
|
|
The signal scoring stage reads windowed counts and velocity values through `ProfileExecutor::score()` -> `helpers::read_agg()`. Under `CoarseAggregates`, the executor should override the window argument.
|
|
|
|
Add a degradation-aware helper that substitutes coarse windows:
|
|
|
|
```rust
|
|
// In tidal/src/ranking/executor/helpers.rs or a new helper module:
|
|
|
|
use crate::load::DegradationLevel;
|
|
use crate::schema::Window;
|
|
|
|
/// Adjust the window for signal reads under degradation.
|
|
///
|
|
/// Under `CoarseAggregates` or `NoDiversity`:
|
|
/// - Windowed count requests fall back to `AllTime` (cheapest read)
|
|
/// - Velocity requests fall back to `TwentyFourHours` (widest cached window)
|
|
///
|
|
/// This avoids per-bucket scans in SWAG and uses pre-aggregated values.
|
|
#[must_use]
|
|
pub const fn degraded_window(
|
|
window: Window,
|
|
degradation: DegradationLevel,
|
|
) -> Window {
|
|
if degradation.coarsens_aggregates() {
|
|
Window::AllTime
|
|
} else {
|
|
window
|
|
}
|
|
}
|
|
|
|
/// Adjust the velocity window under degradation.
|
|
#[must_use]
|
|
pub const fn degraded_velocity_window(
|
|
window: Window,
|
|
degradation: DegradationLevel,
|
|
) -> Window {
|
|
if degradation.coarsens_aggregates() {
|
|
Window::TwentyFourHours
|
|
} else {
|
|
window
|
|
}
|
|
}
|
|
```
|
|
|
|
Wire this into `ProfileExecutor` by threading the `DegradationLevel` through the scoring path. The cleanest approach: add an optional `DegradationLevel` field to `ProfileExecutor`:
|
|
|
|
```rust
|
|
impl<'a> ProfileExecutor<'a> {
|
|
#[must_use]
|
|
pub const fn with_degradation(mut self, level: DegradationLevel) -> Self {
|
|
self.degradation_level = level;
|
|
self
|
|
}
|
|
}
|
|
```
|
|
|
|
Then in the `read_agg` call sites inside `score_candidate()`, apply the degradation window substitution.
|
|
|
|
### 5. Stage 4 -- NoDiversity: skip diversity enforcement
|
|
|
|
In `RetrieveExecutor::execute()`, the diversity stage currently runs unconditionally when constraints are present. Under `NoDiversity`, skip it:
|
|
|
|
```rust
|
|
// Stage 4: Diversity Enforcement
|
|
let (final_candidates, constraints_satisfied) =
|
|
if self.degradation_level.skips_diversity() {
|
|
// NoDiversity: skip the diversity pass entirely.
|
|
// Log a warning so the caller knows quality is reduced.
|
|
warnings.push(
|
|
"diversity enforcement skipped due to load degradation".to_string(),
|
|
);
|
|
(scored, true)
|
|
} else if let Some(diversity) = effective_diversity {
|
|
let result = DiversitySelector::select(&scored, diversity, scored.len());
|
|
// ... existing diversity logic ...
|
|
} else {
|
|
(scored, true)
|
|
};
|
|
```
|
|
|
|
Same pattern in `SearchExecutor::execute()`:
|
|
|
|
```rust
|
|
let (final_candidates, constraints_satisfied) =
|
|
if self.degradation_level.skips_diversity() {
|
|
warnings.push(
|
|
"diversity enforcement skipped due to load degradation".to_string(),
|
|
);
|
|
(scored, true)
|
|
} else if let Some(ref diversity) = query.diversity {
|
|
// ... existing diversity logic ...
|
|
} else {
|
|
(scored, true)
|
|
};
|
|
```
|
|
|
|
### 6. Thread degradation level into Results
|
|
|
|
The executor already constructs `Results` at the end of `execute()`. Pass the degradation level through to the response. This is covered in detail by task-03 (which adds the field to `Results` and `SearchResults`), but the executor must set it:
|
|
|
|
```rust
|
|
Ok(Results {
|
|
items,
|
|
next_cursor,
|
|
total_candidates: total_scored,
|
|
constraints_satisfied,
|
|
warnings,
|
|
session_snapshot: self.session_snapshot.clone(),
|
|
degradation_level: self.degradation_level,
|
|
})
|
|
```
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] `RetrieveExecutor` has `degradation_level` field, default `Full`
|
|
- [ ] `SearchExecutor` has `degradation_level` field, default `Full`
|
|
- [ ] `with_degradation_level()` builder method on both executors
|
|
- [ ] ANN `top_k` reduced under `ReducedCandidates` (capped at 200)
|
|
- [ ] BM25 results truncated under `ReducedCandidates` (halved cap)
|
|
- [ ] Scan candidate over-fetch reduced under `ReducedCandidates`
|
|
- [ ] Signal windowed reads fall back to AllTime under `CoarseAggregates`
|
|
- [ ] Velocity reads fall back to 24h under `CoarseAggregates`
|
|
- [ ] Diversity pass skipped under `NoDiversity` with warning
|
|
- [ ] `LoadDetector::enter()` called in `TidalDb::retrieve()` and `TidalDb::search()`
|
|
- [ ] `InFlightGuard` held for the duration of each query method
|
|
- [ ] All existing executor tests still pass (degradation defaults to `Full`)
|
|
- [ ] `cargo clippy -D warnings` clean
|
|
|
|
## Test Strategy
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::load::DegradationLevel;
|
|
|
|
// Test that executor with DegradationLevel::Full behaves identically
|
|
// to the pre-degradation baseline (all existing tests implicitly cover this).
|
|
|
|
#[test]
|
|
fn reduced_candidates_caps_ann_top_k() {
|
|
// Build a SearchExecutor with ReducedCandidates level.
|
|
// Verify the ANN search k parameter is <= 200.
|
|
// This requires either inspecting the k value via a test hook
|
|
// or verifying that fewer candidates are produced.
|
|
}
|
|
|
|
#[test]
|
|
fn no_diversity_skips_enforcement() {
|
|
// Build 10 items all from the same creator.
|
|
// Query with max_per_creator=2 and NoDiversity.
|
|
// All 10 should be returned (diversity not enforced).
|
|
// Verify warning message includes "load degradation".
|
|
}
|
|
|
|
#[test]
|
|
fn no_diversity_adds_warning() {
|
|
// Run a query under NoDiversity.
|
|
// Assert results.warnings contains the degradation message.
|
|
}
|
|
|
|
#[test]
|
|
fn full_level_enforces_diversity() {
|
|
// Same setup as no_diversity_skips_enforcement but with Full level.
|
|
// Only 2 per creator should be returned.
|
|
}
|
|
|
|
// Property test: for any DegradationLevel, a valid query always
|
|
// returns Ok (not Err). This verifies "under 3x overload, all
|
|
// well-formed queries return results."
|
|
mod proptests {
|
|
use super::*;
|
|
use proptest::prelude::*;
|
|
|
|
proptest! {
|
|
#[test]
|
|
fn query_always_succeeds_regardless_of_degradation(
|
|
level in prop::sample::select(vec![
|
|
DegradationLevel::Full,
|
|
DegradationLevel::ReducedCandidates,
|
|
DegradationLevel::CoarseAggregates,
|
|
DegradationLevel::NoDiversity,
|
|
])
|
|
) {
|
|
// Build a minimal executor with the given degradation level.
|
|
// Execute a simple query. Assert Ok.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|