Skip to content
Merged
Changes from 1 commit
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
329 changes: 329 additions & 0 deletions tests/http_api/promote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,3 +755,332 @@ fn the_mcp_promote_tool_reaches_the_endpoint() {
"dry_run through the tool writes nothing"
);
}

// --- budget_refusal: a deadline dying mid-apply-loop -------------------------

/// A scratch context with `count` distinct one-passage sources, each
/// promotable on its own — one batch per source, so a large `count`
/// gives the apply loop enough fsync-bearing iterations to spend a
/// tight budget partway through, the same shape
/// `server_ops.rs`'s `a_tight_timeout_cuts_a_multi_batch_import_short_
/// instead_of_running_it_to_completion` uses for `/import`.
/// A destination vocabulary dense enough to make `merge_section`'s
/// lexical twin sweep (`vocabulary_audit`, O(n^2) over live concepts)
/// take real wall-clock time — reused by both the `deadline_exceeded`
/// and `overloaded` `audit_skip_reason` tests below. `scored_twins`
/// (`src/context/entry_index.rs`) is a posting-list sweep, not a naive
/// all-pairs scan: any bigram shared by more than `stop_gram`
/// (spans.len() / 20) concepts is skipped outright, so a chain of
/// merely-numbered names (a shared long common substring plus a unique
/// digit tail) never gets slow — the shared substring's postings blow
/// past stop_gram and the whole thing is skipped. To actually engage
/// the O(len^2) pass per bigram, names are built in 20 buckets of 47
/// concepts each (47 == stop_gram at this total, so postings stay
/// exactly at the skip boundary, not over it); every concept in a
/// bucket shares the same 300-character run of distinct CJK code
/// points (299 unique bigrams, all with a 47-long posting list), so
/// each bigram alone contributes C(47,2) pair updates — measured at
/// roughly 3s of unchecked work in total (a full free run, not cut
/// short by a deadline), while staying under SWEEP_CAP (2000) so the
/// sweep runs its real pass instead of bailing out early (see
/// vocabulary.rs's own sweep-cap test in issue #626, a different —
/// semantic, not lexical — cap).
fn seed_dense_vocabulary(server: &Server, context: &str) {
const BUCKET_COUNT: usize = 20;
const BUCKET_SIZE: usize = 47;
const PREFIX_LEN: usize = 300;
fn dense_name(k: usize) -> String {
let bucket = k / BUCKET_SIZE;
let item = k % BUCKET_SIZE;
let base = 0x4E00u32 + (bucket * PREFIX_LEN) as u32;
let prefix: String = (0..PREFIX_LEN)
.map(|i| char::from_u32(base + i as u32).unwrap())
.collect();
format!("{prefix}{item:03}")
}
let total = BUCKET_COUNT * BUCKET_SIZE;
let chain: Vec<serde_json::Value> = (0..total - 1)
.map(|k| {
json!({
"subject": dense_name(k),
"label": "次",
"object": dense_name(k + 1),
"weight": 1.0, "source": "seed.md",
})
})
.collect();
server.ok(
"POST",
&format!("/contexts/{context}/associations"),
Some(serde_json::Value::Array(chain)),
);
}

fn seed_many_sources(server: &Server, context: &str, count: usize) {
server.ok(
"PUT",
&format!("/contexts/{context}"),
Some(json!({"description": "d"})),
);
let passages: serde_json::Map<String, serde_json::Value> = (0..count)
.map(|i| (format!("s{i:04}"), json!(format!("本文{i}。"))))
.collect();
server.ok(
"POST",
&format!("/contexts/{context}/sources"),
Some(json!({"passages": passages})),
);
}

/// `budget_refusal` (`promote.rs:552-576`): the apply loop's own
/// per-iteration deadline check trips before all batches land — a
/// resumable-prefix 408, distinct from `deadline_exceeded`'s top-of-
/// handler refusal (which fires before anything runs at all).
#[test]
fn budget_refusal_cuts_a_multi_batch_promotion_short() {
let server = Server::start_with_env(
"promote-budget-refusal",
&[("TAGURU_REQUEST_TIMEOUT_SECS", "1")],
);
const COUNT: usize = 600;
seed_many_sources(&server, "scratch", COUNT);
server.ok("PUT", "/contexts/perm2", Some(json!({"description": "d"})));

let sources: Vec<String> = (0..COUNT).map(|i| format!("s{i:04}")).collect();
let (status, refused) = server.call(
"POST",
"/contexts/scratch/promote",
Some(json!({"into": "perm2", "sources": sources, "audit": false})),
);
assert_eq!(status, 408, "{refused}");
assert_eq!(refused["code"], json!("timeout"), "{refused}");
assert!(
refused["error"]
.as_str()
.unwrap()
.contains("budget partway through the promotion"),
"{refused}"
);
// A resumable prefix, `/import`'s own discipline: some batches
// landed (durable), the rest did not — never all-or-nothing, and
// never silently the full count either.
let landed = refused["durable_batches"].as_u64().unwrap();
assert!(landed > 0 && landed < COUNT as u64, "{refused}");
}

// --- audit_skip_reason: deadline_exceeded, driven through HTTP --------------

/// `deadline_exceeded` (`promote.rs:579-585`, `AccessError::
/// DeadlineExceeded` from any of the three consolidation sections):
/// the write itself is a single small batch (fast, well inside a 1s
/// budget), but the DESTINATION already carries a large enough
/// vocabulary that `merge_section`'s lexical twin sweep
/// (`vocabulary_audit`, O(n^2) over live concepts) alone outlasts
/// what remains of the budget — the deadline dies INSIDE the audit,
/// after every batch above is already durable, not before the write
/// even starts (that is the ordinary top-of-handler check, a
/// different code path).
#[test]
fn audit_degrades_to_deadline_exceeded_when_the_destination_vocabulary_is_too_large_to_audit_in_time()
{
let server = Server::start_with_env(
"promote-audit-deadline",
&[("TAGURU_REQUEST_TIMEOUT_SECS", "1")],
);
server.ok(
"PUT",
"/contexts/scratch",
Some(json!({"description": "d"})),
);
server.ok(
"POST",
"/contexts/scratch/associations",
Some(json!([
{"subject": "s", "label": "l", "object": "o", "weight": 1.0, "source": "a.md"}
])),
);
server.ok("PUT", "/contexts/perm4", Some(json!({"description": "d"})));
// ~1-2s of unchecked lexical-sweep work, comfortably past whatever
// remains of a 1s request budget — see `seed_dense_vocabulary`.
seed_dense_vocabulary(&server, "perm4");

let promoted = server.ok(
"POST",
"/contexts/scratch/promote",
Some(json!({"into": "perm4", "sources": ["a.md"]})),
);
// The batch itself landed — only the audit degraded.
assert_eq!(
promoted["batches"][0]["source"],
json!("a.md"),
"{promoted}"
);
assert!(promoted["audit"].is_null(), "{promoted}");
assert_eq!(
promoted["audit_skipped"],
json!("deadline_exceeded"),
"{promoted}"
);
}

/// `render_refusal`'s Timeout arm (`promote.rs:494-500`): the deadline
/// dies inside `export::render` itself — after `export_context`
/// already succeeded, before the apply loop even starts, so nothing
/// has been written. Distinct from `budget_refusal` (which fires
/// between batches, after render/parse have already finished) and
/// from the plain `AccessError::DeadlineExceeded` a slow
/// `export_context` would raise (a different message: that one never
/// reaches render at all).
///
/// The pipeline is export -> render -> parse -> apply, and only
/// render's own loop checks the deadline per item; `parse_stream` has
/// none (unreachable by construction — the stream is our own render).
/// Measured directly (temporary `eprintln!`s around each phase, since
/// removed): for 400_000 one-source associations, export took ~155ms,
/// render alone ~1.8s, and parse alone ~1.5s more — so a budget that
/// export clears easily but render's own cost alone exceeds is what
/// reaches this branch specifically; too small a corpus lets render
/// finish just under the wire and `parse_stream`'s unchecked cost is
/// what actually crosses it, landing on `budget_refusal` at the next
/// checkpoint instead. 500_000 gives comfortable margin over a 2s
/// budget.
#[test]
fn render_refusal_reports_timeout_when_the_export_alone_outlasts_the_budget() {
let server = Server::start_with_env(
"promote-render-timeout",
&[("TAGURU_REQUEST_TIMEOUT_SECS", "2")],
);
server.ok(
"PUT",
"/contexts/scratch",
Some(json!({"description": "d"})),
);
server.ok("PUT", "/contexts/perm5", Some(json!({"description": "d"})));
const N: usize = 500_000;
const CHUNK: usize = 10_000;
let mut k = 0usize;
while k < N {
let end = (k + CHUNK).min(N);
let batch: Vec<serde_json::Value> = (k..end)
.map(|i| {
json!({
"subject": format!("s{i}"), "label": "l", "object": format!("o{i}"),
"weight": 1.0, "source": "a.md",
})
})
.collect();
server.ok(
"POST",
"/contexts/scratch/associations",
Some(serde_json::Value::Array(batch)),
);
k = end;
}

let (status, refused) = server.call(
"POST",
"/contexts/scratch/promote",
Some(json!({"into": "perm5", "sources": ["a.md"], "audit": false})),
);
assert_eq!(status, 408, "{refused}");
assert_eq!(refused["code"], json!("timeout"), "{refused}");
// render_refusal's own wording ("before this operation could
// start") — distinct from budget_refusal's "partway through the
// promotion" and from export's own AccessError::DeadlineExceeded
// message, which lacks "before this operation could start".
let message = refused["error"].as_str().unwrap();
assert!(
message.contains("before this operation could start"),
"{message}"
);

let sources = server.ok("GET", "/contexts/perm5/sources", None);
assert_eq!(
sources["total"],
json!(0),
"nothing applies before render finishes: {sources}"
);
}

/// The other `audit_skip_reason` arm outside `metadata_unreadable`/
/// `no_context`: `heavy_ops.try_acquire()` failing at the ceiling
/// (`promote.rs:412-415`) — separate code entirely from
/// `audit_skip_reason` itself (that function only maps
/// `AccessError` arms; this one is a plain `Err(_shed)` match before
/// `landing_audit` is ever called). `TAGURU_MAX_CONCURRENT_HEAVY_OPS=1`
/// leaves exactly one permit; two promotions into the SAME
/// dense-vocabulary destination fire from separate threads, staggered
/// just enough that the first is reliably past its own `try_acquire`
/// and into the slow audit before the second starts — the first holds
/// the permit for the audit's whole (seconds-long) duration, so the
/// second's own `try_acquire` finds none, degrading to `"overloaded"`
/// rather than shedding its own already-durable write.
#[test]
fn audit_degrades_to_overloaded_when_a_concurrent_promotion_holds_the_only_heavy_ops_permit() {
let server = Server::start_with_env(
"promote-audit-overloaded",
&[("TAGURU_MAX_CONCURRENT_HEAVY_OPS", "1")],
);
server.ok(
"PUT",
"/contexts/scratch-a",
Some(json!({"description": "d"})),
);
server.ok(
"POST",
"/contexts/scratch-a/associations",
Some(json!([
{"subject": "s1", "label": "l", "object": "o1", "weight": 1.0, "source": "a.md"}
])),
);
server.ok(
"PUT",
"/contexts/scratch-b",
Some(json!({"description": "d"})),
);
server.ok(
"POST",
"/contexts/scratch-b/associations",
Some(json!([
{"subject": "s2", "label": "l", "object": "o2", "weight": 1.0, "source": "b.md"}
])),
);
server.ok("PUT", "/contexts/perm6", Some(json!({"description": "d"})));
seed_dense_vocabulary(&server, "perm6");

let (first, second) = std::thread::scope(|scope| {
let first = scope.spawn(|| {
server.ok(
"POST",
"/contexts/scratch-a/promote",
Some(json!({"into": "perm6", "sources": ["a.md"]})),
)
});
// Give the first request a head start into its (multi-second)
// audit before the second even tries to acquire the permit —
// wide margin against the sweep's own ~1-2s duration.
std::thread::sleep(std::time::Duration::from_millis(200));
let second = scope.spawn(|| {
server.ok(
"POST",
"/contexts/scratch-b/promote",
Some(json!({"into": "perm6", "sources": ["b.md"]})),
)
});
(first.join().unwrap(), second.join().unwrap())
});

// Both batches land regardless of the audit outcome — the permit
// ceiling degrades the audit, it never sheds an already-applied
// write.
assert_eq!(first["batches"][0]["source"], json!("a.md"), "{first}");
assert_eq!(second["batches"][0]["source"], json!("b.md"), "{second}");

// The first request started the audit well before the second
// could contend for the permit, so the second is the one that
// degrades — not a race between the two.
assert!(first["audit"].is_object(), "{first}");
assert!(first.get("audit_skipped").is_none(), "{first}");
assert!(second["audit"].is_null(), "{second}");
assert_eq!(second["audit_skipped"], json!("overloaded"), "{second}");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Loading