fix(memory): graceful empty statuses when DB unavailable (#1292) (#1328)

This commit is contained in:
Mega Mind
2026-05-07 12:26:19 -07:00
committed by GitHub
parent 2a9bc8f58b
commit 2f241ce494
3 changed files with 67 additions and 9 deletions
+6 -4
View File
@@ -44,13 +44,15 @@ describe('memorySyncService.memorySyncStatusList', () => {
await expect(memorySyncStatusList()).rejects.toThrow('rpc boom');
});
it('throws on malformed response (missing statuses[])', async () => {
it('returns empty array on malformed response (missing statuses[])', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ wrong: 'shape' });
await expect(memorySyncStatusList()).rejects.toThrow(/missing statuses/);
const out = await memorySyncStatusList();
expect(out).toEqual([]);
});
it('throws on null response', async () => {
it('returns empty array on null response', async () => {
mockCallCoreRpc.mockResolvedValueOnce(null);
await expect(memorySyncStatusList()).rejects.toThrow(/missing statuses/);
const out = await memorySyncStatusList();
expect(out).toEqual([]);
});
});
+5 -2
View File
@@ -57,8 +57,11 @@ export async function memorySyncStatusList(): Promise<MemorySyncStatus[]> {
throw err;
}
if (!resp || !Array.isArray(resp.statuses)) {
errLog('memory_sync_status_list: malformed response (missing statuses[]): %O', resp);
throw new Error('Invalid response from openhuman.memory_sync_status_list: missing statuses[]');
errLog(
'memory_sync_status_list: malformed response (missing statuses[]), returning empty: %O',
resp
);
return [];
}
log('memory_sync_status_list: received %d row(s)', resp.statuses.length);
return resp.statuses;
+56 -3
View File
@@ -42,7 +42,7 @@ pub async fn status_list_rpc(config: &Config) -> Result<RpcOutcome<StatusListRes
tracing::debug!("[memory_sync_status][rpc] status_list");
let config = config.clone();
let statuses: Vec<MemorySyncStatus> = tokio::task::spawn_blocking(move || {
let statuses: Vec<MemorySyncStatus> = match tokio::task::spawn_blocking(move || {
with_connection(&config, |conn| -> anyhow::Result<Vec<MemorySyncStatus>> {
// Provider parsed from `source_id` prefix (substring before
// first ':'); falls back to `source_kind` when no prefix.
@@ -130,8 +130,23 @@ pub async fn status_list_rpc(config: &Config) -> Result<RpcOutcome<StatusListRes
})
})
.await
.map_err(|e| format!("spawn_blocking join failed: {e}"))?
.map_err(|e| format!("memory_tree DB access failed: {e:#}"))?;
{
Ok(Ok(rows)) => rows,
// DB unavailable (open/migration failure) or query error: return empty
// so the schema contract (`statuses` array) is always satisfied.
Ok(Err(e)) => {
tracing::warn!(
"[memory_sync_status][rpc] DB query failed, returning empty statuses: {e:#}"
);
vec![]
}
Err(e) => {
tracing::warn!(
"[memory_sync_status][rpc] spawn_blocking join error, returning empty statuses: {e}"
);
vec![]
}
};
tracing::debug!(
"[memory_sync_status][rpc] status_list returning {} row(s)",
@@ -143,3 +158,41 @@ pub async fn status_list_rpc(config: &Config) -> Result<RpcOutcome<StatusListRes
// `resp.statuses` directly, so any envelope here breaks parsing.
Ok(RpcOutcome::new(StatusListResponse { statuses }, vec![]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_list_response_serializes_statuses_array() {
let resp = StatusListResponse { statuses: vec![] };
let v = serde_json::to_value(&resp).expect("serialize");
assert!(
v.get("statuses").and_then(|s| s.as_array()).is_some(),
"statuses must always be present as an array"
);
}
#[test]
fn status_list_response_empty_statuses_is_empty_array() {
let resp = StatusListResponse { statuses: vec![] };
let v = serde_json::to_value(&resp).expect("serialize");
let arr = v["statuses"].as_array().unwrap();
assert!(arr.is_empty());
}
#[test]
fn rpc_outcome_no_logs_serializes_bare_value() {
// Validates the wire contract: with empty logs, into_cli_compatible_json
// returns the value directly (not wrapped in { result, logs }).
let resp = StatusListResponse { statuses: vec![] };
let outcome = RpcOutcome::new(resp, vec![]);
let json = outcome.into_cli_compatible_json().expect("serialize");
assert!(
json.get("statuses").is_some(),
"bare value must have statuses at the top level"
);
assert!(json.get("result").is_none(), "must not be double-wrapped");
assert!(json.get("logs").is_none(), "must not be double-wrapped");
}
}