fix: budget subjektiv batches for compact verified Job results (T-704)
This commit is contained in:
@@ -147,6 +147,29 @@ pub(super) async fn retire_legacy_worker(api: &WorkspaceApi, subject_id: &str) -
|
||||
.is_none())
|
||||
}
|
||||
|
||||
fn bounded_candidate_batch(
|
||||
ids: impl IntoIterator<Item = String>,
|
||||
max_result_bytes: u32,
|
||||
) -> Result<Vec<String>> {
|
||||
// Reserve the bounded subject and Host surface envelope. Each candidate ID
|
||||
// appears twice (input claims and verified outcome); account for JSON escaping,
|
||||
// not raw UTF-8 bytes. The fixed per-item allowance covers action and framing.
|
||||
let mut estimated_bytes = 2_048_usize;
|
||||
let mut selected = Vec::new();
|
||||
for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) {
|
||||
let id_bytes = serde_json::to_vec(&id)
|
||||
.map_err(|e| Error::InvalidInput(e.to_string()))?
|
||||
.len();
|
||||
let additional = 2 * id_bytes + 128;
|
||||
if estimated_bytes + additional > max_result_bytes as usize {
|
||||
break;
|
||||
}
|
||||
estimated_bytes += additional;
|
||||
selected.push(id);
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
pub(super) fn dispatch(
|
||||
api: &WorkspaceApi,
|
||||
subject_id: &str,
|
||||
@@ -158,12 +181,14 @@ pub(super) fn dispatch(
|
||||
.subject(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.ok_or_else(|| Error::SubjektivSubjectNotFound(subject_id.into()))?;
|
||||
let candidate_ids: Vec<_> = store
|
||||
.pending_staging_candidates(subject_id, CANDIDATE_BATCH_LIMIT)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.into_iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let candidate_ids = bounded_candidate_batch(
|
||||
store
|
||||
.pending_staging_candidates(subject_id, CANDIDATE_BATCH_LIMIT)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.into_iter()
|
||||
.map(|c| c.id),
|
||||
crate::backend_job::DEFAULT_MAX_RESULT_BYTES,
|
||||
)?;
|
||||
let batch_len = candidate_ids.len();
|
||||
let request = BackendJobRequest {
|
||||
job_id: format!("subjektiv-consolidation:{}", Uuid::new_v4()),
|
||||
@@ -296,8 +321,10 @@ pub(super) fn with_dispositions(
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidInput(format!("candidate `{id}` has no durable disposition"))
|
||||
})?;
|
||||
dispositions
|
||||
.push(serde_json::to_value(resolution).map_err(|e| Error::Store(e.to_string()))?);
|
||||
// Full reasons, affected revisions and source edges remain in the existing
|
||||
// immutable receipt store. Duplicating them makes a valid bounded batch's
|
||||
// completion unbounded; this result is its verified compact projection.
|
||||
dispositions.push(serde_json::json!({ "candidate_id": id, "action": resolution.action }));
|
||||
}
|
||||
let mut enriched = value.clone();
|
||||
enriched
|
||||
@@ -309,3 +336,34 @@ pub(super) fn with_dispositions(
|
||||
);
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod result_budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escaped_maximum_ids_leave_a_bounded_complete_result() {
|
||||
let ids: Vec<_> = (0..100)
|
||||
.map(|i| format!("{i:03}{}", "\\\"".repeat(126)))
|
||||
.collect();
|
||||
let batch =
|
||||
bounded_candidate_batch(ids.clone(), crate::backend_job::DEFAULT_MAX_RESULT_BYTES)
|
||||
.unwrap();
|
||||
assert!(!batch.is_empty());
|
||||
assert!(batch.len() < ids.len());
|
||||
assert_eq!(batch, ids[..batch.len()]);
|
||||
let dispositions: Vec<_> = batch
|
||||
.iter()
|
||||
.map(|id| serde_json::json!({"candidate_id":id,"action":"already_covered"}))
|
||||
.collect();
|
||||
let result = serde_json::json!({
|
||||
"subject_id":"\\\"".repeat(128),"candidate_ids":batch,
|
||||
"candidate_dispositions":dispositions,
|
||||
"surface":{"availability":"failed","generation_id":"g".repeat(100),"store_revision":u64::MAX,"reason_code":"\\\"".repeat(128)},
|
||||
});
|
||||
assert!(
|
||||
serde_json::to_vec(&result).unwrap().len()
|
||||
<= crate::backend_job::DEFAULT_MAX_RESULT_BYTES as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,3 +607,126 @@ async fn subjektiv_job_legacy_cutover_waits_foreground_then_removes_only_owned_c
|
||||
assert!(api.runtime.worker(&subject_body).is_ok());
|
||||
assert!(store.subject(&subject.id).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_job_long_receipts_and_escaped_ids_fit_result_budget_without_losing_followup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _) = test_api_with_recording_backend(dir.path()).await;
|
||||
let store = open_subjektiv_store(&api).unwrap();
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("result-budget").unwrap())
|
||||
.unwrap();
|
||||
for i in 0..100 {
|
||||
stage(
|
||||
&store,
|
||||
&subject.id,
|
||||
&format!("candidate-{i:03}-{}", "\\\"".repeat(60)),
|
||||
);
|
||||
}
|
||||
start_subjektiv_staging_consolidation(
|
||||
api.clone(),
|
||||
&subject.id,
|
||||
MemoryConsolidateStagingOperation { force: true },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let job = resource(&api, &subject.id);
|
||||
let ids = job
|
||||
.request
|
||||
.grants
|
||||
.subjektiv_consolidation
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidate_ids
|
||||
.clone();
|
||||
assert!(!ids.is_empty() && ids.len() < 100);
|
||||
let attempt = current_attempt(&api, &job);
|
||||
let worker = attempt.worker.as_ref().unwrap();
|
||||
for id in &ids {
|
||||
store
|
||||
.decide_candidate(
|
||||
&subject.id,
|
||||
CandidateDecisionRequest {
|
||||
request_id: format!("decision-{}", uuid::Uuid::new_v4()),
|
||||
candidate_id: id.clone(),
|
||||
reason: "Existing confirmed detail remains in its immutable receipt. "
|
||||
.repeat(500),
|
||||
decision: CandidateDecision::Close {
|
||||
action: crate::subjektiv::StagingResolutionAction::Discarded,
|
||||
affected_memory: vec![],
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let generation = store
|
||||
.prepare_job_surface_generation(
|
||||
&subject.id,
|
||||
Some((&job.request.job_id, &attempt.attempt_id)),
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot = store
|
||||
.publish_surface_generation(&subject.id, &generation.id, vec![])
|
||||
.unwrap();
|
||||
let submission = BackendJobResultSubmission {
|
||||
job_id: job.request.job_id.clone(),
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
input_revision: job.request.input_revision.clone(),
|
||||
result: serde_json::json!({"subject_id":subject.id,"candidate_ids":ids,"surface":{"availability":"ready","generation_id":generation.id,"store_revision":generation.store_revision,"snapshot_id":snapshot.id}}),
|
||||
};
|
||||
seed_worker_session_for_cleanup(dir.path(), worker);
|
||||
let accepted = api.accept_backend_job_result(worker, &submission).unwrap();
|
||||
let result = accepted.job.result.as_ref().unwrap();
|
||||
assert!(
|
||||
serde_json::to_vec(result).unwrap().len() <= job.request.limits.max_result_bytes as usize
|
||||
);
|
||||
let outcomes = result["candidate_dispositions"].as_array().unwrap();
|
||||
assert_eq!(outcomes.len(), ids.len());
|
||||
assert!(
|
||||
outcomes
|
||||
.iter()
|
||||
.all(|o| o["action"] == "discarded" && o.get("reason").is_none())
|
||||
);
|
||||
assert_eq!(
|
||||
store.pending_staging_backlog(&subject.id).unwrap().0,
|
||||
100 - ids.len()
|
||||
);
|
||||
assert!(
|
||||
api.accept_backend_job_result(worker, &submission)
|
||||
.unwrap()
|
||||
.replayed
|
||||
);
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
&api,
|
||||
&job.request.job_id,
|
||||
&attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
start_subjektiv_staging_consolidation(
|
||||
api.clone(),
|
||||
&subject.id,
|
||||
MemoryConsolidateStagingOperation { force: true },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let next = resource(&api, &subject.id);
|
||||
assert!(
|
||||
next.request
|
||||
.grants
|
||||
.subjektiv_consolidation
|
||||
.unwrap()
|
||||
.candidate_ids
|
||||
.iter()
|
||||
.all(|id| !ids.contains(id))
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.staging_resolution(&subject.id, &ids[0])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.reason
|
||||
.len()
|
||||
> 16 * 1024
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user