Skip to content

fix(data-connector): harden storage backends against data corruption and races - #505

Merged
slin1237 merged 4 commits into
mainfrom
slin/db-fixes
Feb 22, 2026
Merged

slin1237 merged 4 commits into
mainfrom
slin/db-fixes

Conversation

@slin1237

@slin1237 slin1237 commented Feb 22, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes 5 production-readiness issues in the data_connector storage backends (Postgres, Memory, Redis) identified during code review of #504 (commit 363bbe25).

Refs: 363bbe25 (refactor(data-connector): comprehensive code quality improvements)

What changed

data_connector/src/postgres.rs

  • Fix 1: Replaced inline parse_metadata with delegation to the shared crate::common::parse_conversation_metadata helper, matching Oracle and Redis backends. The Postgres-specific version had subtly different whitespace/null handling.

data_connector/src/memory.rs

  • Fix 2: Consolidated get_response_chain from two separate lock acquisitions into a single read-lock scope. The original code collected chain IDs under one lock, dropped it, then re-acquired to fetch responses — allowing concurrent writers to delete entries between locks and silently lose chain data.

data_connector/src/redis.rs

  • Fix 3: Added build_item_from_map helper with proper error propagation for corrupted content (serde failures) and missing/invalid created_at timestamps. Previously, malformed data was silently replaced with Value::Null or Utc::now(), masking data corruption. Also hardened build_response_from_map with the same created_at error handling.
  • Fix 4: Replaced sequential HGET + DEL in delete_response with a Redis pipeline for atomic read+delete. Eliminates a TOCTOU race where the safety_identifier could change between the read and the delete.
  • Fix 5: Fixed cursor pagination in list_items to use inclusive score bounds with post-filtering by item_id, matching the composite (added_at, item_id) cursor semantics of Postgres and Oracle. The exclusive-bound approach silently skipped items sharing the same millisecond timestamp as the cursor.

Why

The original refactor commit introduced several patterns that could cause silent data loss or corruption in production:

  • Double lock in memory backend creates a TOCTOU window for chain data loss
  • Silent fallback to Utc::now() and Value::Null in Redis masks corrupted records
  • Non-atomic read+delete in Redis allows race conditions under concurrent access
  • Inconsistent cursor pagination between Redis and SQL backends causes different query results depending on storage backend

How

  • Postgres: one-line delegation to existing shared helper
  • Memory: restructured to single lock scope with in-place chain collection and post-reversal
  • Redis items: extracted build_item_from_map that propagates SerializationError and StorageError instead of substituting defaults
  • Redis responses: build_response_from_map now errors on missing/invalid created_at
  • Redis delete: redis::pipe() batches HGET + DEL into one round-trip
  • Redis pagination: inclusive ZRANGEBYSCORE/ZREVRANGEBYSCORE bounds with over-fetch (+32) and post-filter to skip the cursor item, then take(limit)

Test plan

  • All 106 existing unit tests pass: cargo test -p data-connector
  • Clean build: cargo check -p data-connector
  • No new warnings

Summary by CodeRabbit

  • Documentation

    • Added a comprehensive README for the data-connector covering architecture, backends, configuration, usage, data models, and testing.
  • Improvements

    • Clearer Postgres/Redis validation messages.
    • Centralized metadata parsing and more consistent storage handling.
    • Performance and atomicity improvements in memory and Redis flows.
    • Reduced public visibility for Redis storage types.
  • Bug Fixes

    • More robust retrieval and atomic deletions to prevent inconsistent state.
  • Tests

    • Broadly expanded unit test coverage across all storage backends.
  • Chores

    • Removed a workspace flag from a dependency declaration.

- Remove unused `dashmap` dependency from Cargo.toml
- Fix grammatically broken error messages in PostgresConfig::validate()
- Optimize hex string generation using std::fmt::Write with pre-allocated buffer
- Fix double-serialization bug in postgres.rs for tool_calls and metadata fields
- Add missing responses_safety_idx index in postgres schema initialization
- Rename build_response_from_now → build_response_from_row for clarity
- Reduce unnecessary cloning in postgres.rs by destructuring and using references
- Extract build_response_from_map helper in redis.rs to eliminate ~50 lines of duplication
- Consolidate parse_conversation_metadata into common.rs, used by all 3 backends
- Fix redis storage struct visibility from pub to pub(super) for consistency
- Add 106 unit tests across all modules (config, core, common, noop, memory, factory)
- Add README.md with architecture, configuration, and usage documentation

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
…and races

Fix 5 production-readiness issues identified during code review:

1. postgres.rs: Delegate parse_metadata to shared
   crate::common::parse_conversation_metadata helper, aligning with
   Oracle and Redis backends that already use it. The inline Postgres
   version had subtly different whitespace/null handling.

2. memory.rs: Consolidate get_response_chain from two lock acquisitions
   into a single read-lock scope. The previous code collected IDs under
   one lock, dropped it, then re-acquired to fetch responses — allowing
   concurrent writers to delete chain entries between locks and silently
   lose data.

3. redis.rs: Add build_item_from_map helper that returns proper errors
   for corrupted content (serde failures) and missing/invalid created_at
   timestamps. Previously, malformed data was silently replaced with
   Value::Null or Utc::now(), masking data corruption in production.
   Also harden build_response_from_map with the same created_at error
   propagation.

4. redis.rs: Replace sequential HGET + DEL in delete_response with a
   Redis pipeline so the safety_identifier read and key deletion happen
   atomically. Eliminates a race where the identifier could change
   between the read and the delete.

5. redis.rs: Fix cursor pagination in list_items to use inclusive score
   bounds with post-filtering by item_id, matching the composite
   (added_at, item_id) cursor semantics of Postgres and Oracle. The
   previous exclusive-bound approach silently skipped items sharing the
   same millisecond timestamp as the cursor.

All 106 existing tests pass.

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
@slin1237
slin1237 requested a review from key4ng as a code owner February 22, 2026 03:10
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Dependency updates data-connector Data connector crate changes labels Feb 22, 2026
@coderabbitai

coderabbitai Bot commented Feb 22, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Centralizes metadata parsing, refactors hex ID generation, tightens Redis storage visibility, simplifies DB call-sites and ownership handling, makes in-memory response-chain traversal single-pass under one lock, removes a dashmap workspace flag, and adds extensive unit tests plus a new crate README.

Changes

Cohort / File(s) Summary
Manifest
data_connector/Cargo.toml
Removed dashmap.workspace = true from dependencies.
Documentation
data_connector/README.md
Added a comprehensive crate README describing backends, architecture, config, data model, ID schemes, schema, usage, and testing guidance.
Shared utils & parsing
data_connector/src/common.rs
Added pub(super) fn parse_conversation_metadata(raw: Option<String>) -> Result<Option<ConversationMetadata>, String> and tests to centralize JSON metadata parsing and normalize None/empty/"null".
Config validation & tests
data_connector/src/config.rs
Adjusted validation error messages and added extensive unit tests for PostgresConfig, RedisConfig, and HistoryBackend serde/validation.
Core ID generation & tests
data_connector/src/core.rs
Refactored hex ID construction to use write! into a preallocated buffer, added early-return handling for item types, and expanded ID/formatting unit tests.
In-memory storage & tests
data_connector/src/memory.rs
Rewrote get_response_chain to traverse under a single read lock (collect then reverse after drop); added many conversation/item/response unit tests.
No-op storage tests
data_connector/src/noop.rs
Appended comprehensive tests for NoOp storages covering ID generation and no-op semantics.
Oracle storage
data_connector/src/oracle.rs
Delegated metadata parsing to shared helper; refactored create/store flows to destructure inputs and move ownership safely for DB operations; added doc note about thread-safety for env configuration.
Postgres storage
data_connector/src/postgres.rs
Centralized metadata parsing, removed per-call ID clones in SQL bindings (use id.0.as_str()), restructured item/response construction, updated query bindings, and renamed build_response_from_now → build_response_from_row.
Redis storage
data_connector/src/redis.rs
Tightened storage types to pub(super); added build_item_from_map and build_response_from_map; standardized (de)serialization; improved cursor tie-breaking, atomic deletions with safety-index cleanup, and allocation behavior for bulk ops.

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

tests

Suggested reviewers

  • key4ng
  • CatherineSue

Poem

"I hopped through crates with whiskers bright,
parsed metadata under moonlight,
stamped hex IDs with careful write,
one-lock chains and Redis tucked tight,
tests aplenty — code feels light." 🥕🐇

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the primary change: hardening storage backends against data corruption and race conditions across Postgres, Memory, and Redis implementations.
Docstring Coverage ✅ Passed Docstring coverage is 85.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch slin/db-fixes

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @slin1237, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses several critical production-readiness issues across the data-connector's Postgres, Memory, and Redis storage backends. The changes focus on hardening these backends against data corruption, eliminating race conditions, and ensuring consistent behavior, particularly in areas like metadata parsing, response chain retrieval, data serialization, atomic operations, and cursor-based pagination. The overall impact is a more robust and reliable data storage layer, reducing the risk of silent data loss or inconsistent query results.

Highlights

  • Postgres Metadata Parsing Standardized: Replaced an inline parse_metadata function with a shared helper (crate::common::parse_conversation_metadata) in the Postgres backend, ensuring consistent whitespace and null handling across backends.
  • Memory Backend Race Condition Fixed: Consolidated two separate lock acquisitions in MemoryResponseStorage::get_response_chain into a single read-lock scope, preventing potential data loss due to concurrent writes between lock releases.
  • Redis Data Corruption Handling Improved: Introduced build_item_from_map and hardened build_response_from_map in the Redis backend to properly propagate errors for corrupted content (serde failures) and missing/invalid created_at timestamps, instead of silently substituting defaults.
  • Redis Atomic Deletion Implemented: Replaced sequential HGET and DEL operations in RedisResponseStorage::delete_response with a Redis pipeline, ensuring atomic read-and-delete and eliminating a Time-of-Check to Time-of-Use (TOCTOU) race condition.
  • Redis Cursor Pagination Corrected: Adjusted RedisConversationItemStorage::list_items to use inclusive score bounds with post-filtering by item_id, aligning its cursor pagination semantics with Postgres and Oracle backends and preventing silent skipping of items.
  • Extensive Unit Test Coverage Added: Significantly expanded unit tests across common.rs, config.rs, core.rs, memory.rs, and noop.rs to validate new logic and existing functionality.
  • New README.md Document: Added a comprehensive README.md file for the data-connector crate, detailing its architecture, core traits, supported backends, usage, configuration, data model, ID generation, and database schema.
Changelog
  • data_connector/Cargo.toml
    • Removed dashmap dependency.
  • data_connector/README.md
    • Added new comprehensive documentation for the data-connector crate.
  • data_connector/src/common.rs
    • Introduced a shared parse_conversation_metadata helper function.
    • Added unit tests for parse_conversation_metadata.
  • data_connector/src/config.rs
    • Refined error messages for PostgresConfig validation.
    • Added extensive unit tests for PostgresConfig, RedisConfig, and HistoryBackend serialization/deserialization.
  • data_connector/src/core.rs
    • Updated ID generation logic for ConversationId and ConversationItemId for efficiency using write!.
    • Added comprehensive unit tests for ID types, Conversation, StoredResponse, and ResponseChain.
  • data_connector/src/memory.rs
    • Refactored get_response_chain to use a single read-lock for atomic chain collection.
    • Added new unit tests for MemoryConversationStorage.
  • data_connector/src/noop.rs
    • Added extensive unit tests for all NoOp storage implementations.
  • data_connector/src/oracle.rs
    • Added a thread-safety note for configure_oracle_env.
    • Delegated parse_metadata to the common helper.
    • Refactored create_item and store_response for improved clarity and reduced cloning.
  • data_connector/src/postgres.rs
    • Delegated parse_metadata to the common helper.
    • Updated various methods (get_conversation, update_conversation, delete_conversation, create_item, link_item, list_items, get_item, is_item_linked, unlink_item, store_response, get_response, delete_response, list_identifier_responses, delete_identifier_responses) to use as_str() for ID types where appropriate.
    • Refactored create_item and store_response for clarity by destructuring input structs.
    • Renamed build_response_from_now to build_response_from_row.
    • Added a responses_safety_idx index to the responses table.
  • data_connector/src/redis.rs
    • Changed visibility of Redis storage structs (RedisConversationStorage, RedisConversationItemStorage, RedisResponseStorage) to pub(super).
    • Introduced build_item_from_map and build_response_from_map for robust error handling of corrupted data.
    • Implemented inclusive score bounds with post-filtering for list_items pagination to match other backends.
    • Used a Redis pipeline for atomic delete_response to prevent TOCTOU race conditions.
Activity
  • No specific activity (comments, reviews, progress updates) has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47431735fd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread data_connector/src/redis.rs Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The pull request significantly hardens the storage backends by addressing race conditions, improving error propagation, and aligning pagination behavior across different databases. Key improvements include consolidating lock scopes in the memory backend to prevent TOCTOU races and extracting shared metadata parsing logic. However, the Redis implementation of cursor-based pagination tie-breaking is currently incorrect and could lead to duplicate items in result sets. Additionally, the Redis deletion logic, while improved with a pipeline, is not yet atomic and remains susceptible to rare race conditions. Addressing these points will ensure the backends are truly production-ready.

Comment thread data_connector/src/redis.rs
Comment thread data_connector/src/redis.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data_connector/src/redis.rs`:
- Around line 403-467: The current post-filter only compares IDs and assumes
tied-score items are at the result boundary; instead modify the retrieval to
fetch (score, id) pairs (use the zrange/zrevrangebyscore variant that returns
withscores — e.g., zrangebyscore_withscores_limit /
zrevrangebyscore_withscores_limit) into item_ids (rename to items_with_scores),
then implement composite-key filtering using cursor_score and cursor_id: for
SortOrder::Asc keep items where (score > cursor_score) OR (score == cursor_score
AND id > cursor_id); for SortOrder::Desc keep items where (score < cursor_score)
OR (score == cursor_score AND id < cursor_id); finally take params.limit and
collect IDs, preserving the existing over-fetch logic and error handling around
zscore, cursor_score, cursor_id, and fetch_limit.

Comment thread data_connector/src/redis.rs
…and pipeline atomicity

1. redis.rs list_items: Replace `filter(|id| id != c_id)` with
   `skip_while(|id| id != c_id).skip(1)` for cursor post-filtering.
   Redis returns same-score members in lexicographic order, so the
   previous filter incorrectly included same-score predecessors that
   belonged to the previous page, causing duplicate items across pages.

2. redis.rs delete_response: Add `.atomic()` to the Redis pipeline so
   HGET + DEL are wrapped in MULTI/EXEC. A plain pipeline only batches
   commands without transactional guarantees — other clients can still
   interleave between the two operations.

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data_connector/src/redis.rs`:
- Around line 250-295: The function build_item_from_map currently defaults
item_type to an empty string which hides corrupted/missing data; change the
extraction to fail fast: replace the item_type =
map.get("item_type").cloned().unwrap_or_default() line with logic that returns
Err(ConversationItemStorageError::StorageError(...)) when "item_type" is missing
or when its value is the empty string, e.g. use map.get("item_type").map(|s|
s.clone()).filter(|s| !s.is_empty()).ok_or_else(||
ConversationItemStorageError::StorageError(format!("item {fallback_id} missing
or empty item_type"))) so the function returns an error for missing/empty
item_type instead of silently accepting "".

---

Duplicate comments:
In `@data_connector/src/redis.rs`:
- Around line 405-459: The current post-filtering using zscore +
zrangebyscore_limit can drop items if the cursor id is missing or the same-score
group exceeds the overfetch; instead obtain the cursor position with
ZRANK/ZREVRANK (use the same SortOrder branch where you currently call zscore
and the item fetch), compute a start index = rank + 1, then fetch via
ZRANGE/ZREVRANGE with start and (limit + padding) to ensure you include
subsequent items; finally trim the returned Vec to params.limit without relying
on skip_while/skip(1). Update code paths that reference cursor_score/cursor_id,
zscore, zrangebyscore_limit, zrevrangebyscore_limit and replace the post-filter
block with rank-based start/limit logic while still falling back to the existing
range-by-score behavior if ZRANK fails or cursor is not present.

Comment thread data_connector/src/redis.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f890d07e74

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread data_connector/src/redis.rs
…item_from_map

Fail fast when item_type is missing or empty instead of silently
defaulting to "". This aligns with the error-propagation approach
already used for content and created_at in the same function.

Signed-off-by: Simo Lin <linsimo.mark@gmail.com>
Comment thread data_connector/src/redis.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data_connector/src/redis.rs`:
- Around line 427-459: The current +32 over-fetch (fetch_limit) can still fail
when many members share the same score; fix by fetching member scores and doing
exact (score, id) cursor comparison instead of relying on a fixed margin. Change
the calls to the Redis fetch functions (currently zrangebyscore_limit /
zrevrangebyscore_limit) to versions that return WITHSCORES (or use a helper that
returns Vec<(String, f64)>), then in the post-filter use the tuple (score, id)
to skip until you hit the exact (cursor_score, cursor_id) pair and drop only
that one, finally take params.limit results; alternatively, if WITHSCORES
variants are unavailable, increase fetch_limit conservatively (e.g., to
params.limit + some larger bound) and document the limitation. Ensure you update
the item_ids handling (cursor_score, cursor_id, fetch_limit, and the
post-filter) to operate on scored items.

---

Duplicate comments:
In `@data_connector/src/redis.rs`:
- Line 262: The current deserialization silently defaults item_type via let
item_type = map.get("item_type").cloned().unwrap_or_default(); — change this to
propagate an error when "item_type" is missing or empty so corrupted data isn't
accepted; locate the deserialization code that sets item_type (the variable
named item_type) and replace the unwrap_or_default logic with a check that
returns a Result::Err (or appropriate error variant) when map.get("item_type")
is None or the string is empty, ensuring callers (and make_item_id usage)
receive the error instead of an empty prefix.

Comment on lines +427 to 459
// Over-fetch to handle same-score ties that need filtering
let fetch_limit = if cursor_score.is_some() {
// Fetch extra to compensate for items we'll filter out at the cursor boundary
(params.limit + 32) as isize
} else {
params.limit as isize
};

let item_ids: Vec<String> = match params.order {
SortOrder::Asc => {
// ZRANGEBYSCORE key min max LIMIT offset count
conn.zrangebyscore_limit(&key, min, max, 0, params.limit as isize)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?
}
SortOrder::Desc => {
// ZREVRANGEBYSCORE key max min LIMIT offset count
conn.zrevrangebyscore_limit(&key, max, min, 0, params.limit as isize)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?
}
SortOrder::Asc => conn
.zrangebyscore_limit(&key, min, max, 0, fetch_limit)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?,
SortOrder::Desc => conn
.zrevrangebyscore_limit(&key, max, min, 0, fetch_limit)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?,
};

// Post-filter: skip past the cursor item and all same-score predecessors.
// Redis returns same-score members in lexicographic order (ASC) or
// reverse-lex (DESC), so `skip_while` advances past items that appeared
// on the previous page, then `skip(1)` drops the cursor item itself.
let item_ids: Vec<String> = if let (Some(_), Some(ref c_id)) = (cursor_score, &cursor_id) {
item_ids
.into_iter()
.skip_while(|id| id != c_id)
.skip(1)
.take(params.limit)
.collect()
} else {
item_ids.into_iter().take(params.limit).collect()
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

The +32 over-fetch margin may be insufficient when many items share the same score.

The skip_while(|id| id != cursor_id).skip(1) logic correctly implements cursor pagination by relying on Redis's documented lexicographic ordering of same-score members. However, if more than 32 items share the cursor's millisecond timestamp, the post-filter will skip past the entire over-fetched buffer and return fewer than params.limit items.

For typical timestamps with low collision rates, this works fine. In high-cardinality scenarios (e.g., bulk inserts with the same timestamp), consider increasing the margin or fetching WITHSCORES to filter by exact (score, id) comparison, which avoids relying on arbitrary margin sizing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data_connector/src/redis.rs` around lines 427 - 459, The current +32
over-fetch (fetch_limit) can still fail when many members share the same score;
fix by fetching member scores and doing exact (score, id) cursor comparison
instead of relying on a fixed margin. Change the calls to the Redis fetch
functions (currently zrangebyscore_limit / zrevrangebyscore_limit) to versions
that return WITHSCORES (or use a helper that returns Vec<(String, f64)>), then
in the post-filter use the tuple (score, id) to skip until you hit the exact
(cursor_score, cursor_id) pair and drop only that one, finally take params.limit
results; alternatively, if WITHSCORES variants are unavailable, increase
fetch_limit conservatively (e.g., to params.limit + some larger bound) and
document the limitation. Ensure you update the item_ids handling (cursor_score,
cursor_id, fetch_limit, and the post-filter) to operate on scored items.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df2c2eb0b3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +461 to +462
.skip_while(|id| id != c_id)
.skip(1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard missing cursor before skipping Redis page entries

In list_items, the cursor post-filter skip_while(|id| id != c_id).skip(1) assumes the cursor member is present in the fetched item_ids. If the cursor item is deleted/expired after ZSCORE but before ZRANGEBYSCORE, c_id is absent and skip_while consumes the entire iterator, so the API returns an empty page even though later items still exist. This creates pagination gaps under concurrent deletes/retention expiry; only apply this skip logic when the cursor ID is actually found in the fetched window.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data_connector/src/redis.rs`:
- Around line 250-279: build_item_from_map currently treats a missing "content"
key as Value::Null which hides corrupted/missing data; change the logic in
build_item_from_map so that when map.get("content") is None it returns
Err(ConversationItemStorageError::StorageError(...)) (similar to how missing
"item_type" is handled), using the fallback_id to construct a clear error
message; keep the existing serde_json::from_str error mapping for
present-but-invalid JSON.
- Around line 411-467: The post-filtering currently assumes the cursor id
(cursor_id / params.after) is present in the fetched item_ids, which races if
the cursor member expired and causes empty pages; update the logic after
fetching item_ids (from zrangebyscore_limit / zrevrangebyscore_limit) to check
whether c_id is actually present in the returned Vec: if c_id is found, keep the
existing skip_while(|id| id != c_id).skip(1).take(params.limit).collect()
behavior, but if c_id is not found, fall back to score-only pagination by simply
taking the first params.limit items from the fetched set (i.e.,
item_ids.into_iter().take(params.limit).collect()), thus preserving pagination
when the cursor member is missing.

Comment on lines +250 to +279
/// Parse a Redis hash map into a `ConversationItem`, returning errors for
/// corrupted data instead of silently substituting defaults.
fn build_item_from_map(
map: &std::collections::HashMap<String, String>,
fallback_id: &str,
) -> Result<ConversationItem, ConversationItemStorageError> {
let id = ConversationItemId(
map.get("id")
.cloned()
.unwrap_or_else(|| fallback_id.to_string()),
);
let response_id = map.get("response_id").cloned();
let item_type = map
.get("item_type")
.filter(|s| !s.is_empty())
.cloned()
.ok_or_else(|| {
ConversationItemStorageError::StorageError(format!(
"item {fallback_id} missing item_type"
))
})?;
let role = map.get("role").cloned();
let status = map.get("status").cloned();

let content = match map.get("content") {
Some(s) => {
serde_json::from_str(s).map_err(ConversationItemStorageError::SerializationError)?
}
None => Value::Null,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat missing content as corruption (don’t default to Null).
The helper’s doc comment says it surfaces corrupted data, but a missing content key silently becomes Value::Null, masking corruption and making it indistinguishable from an explicit null. Consider failing fast like item_type/created_at.

🔧 Proposed fix
-        let content = match map.get("content") {
-            Some(s) => {
-                serde_json::from_str(s).map_err(ConversationItemStorageError::SerializationError)?
-            }
-            None => Value::Null,
-        };
+        let content_str = map.get("content").ok_or_else(|| {
+            ConversationItemStorageError::StorageError(format!(
+                "item {fallback_id} missing content"
+            ))
+        })?;
+        let content =
+            serde_json::from_str(content_str).map_err(ConversationItemStorageError::SerializationError)?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Parse a Redis hash map into a `ConversationItem`, returning errors for
/// corrupted data instead of silently substituting defaults.
fn build_item_from_map(
map: &std::collections::HashMap<String, String>,
fallback_id: &str,
) -> Result<ConversationItem, ConversationItemStorageError> {
let id = ConversationItemId(
map.get("id")
.cloned()
.unwrap_or_else(|| fallback_id.to_string()),
);
let response_id = map.get("response_id").cloned();
let item_type = map
.get("item_type")
.filter(|s| !s.is_empty())
.cloned()
.ok_or_else(|| {
ConversationItemStorageError::StorageError(format!(
"item {fallback_id} missing item_type"
))
})?;
let role = map.get("role").cloned();
let status = map.get("status").cloned();
let content = match map.get("content") {
Some(s) => {
serde_json::from_str(s).map_err(ConversationItemStorageError::SerializationError)?
}
None => Value::Null,
};
/// Parse a Redis hash map into a `ConversationItem`, returning errors for
/// corrupted data instead of silently substituting defaults.
fn build_item_from_map(
map: &std::collections::HashMap<String, String>,
fallback_id: &str,
) -> Result<ConversationItem, ConversationItemStorageError> {
let id = ConversationItemId(
map.get("id")
.cloned()
.unwrap_or_else(|| fallback_id.to_string()),
);
let response_id = map.get("response_id").cloned();
let item_type = map
.get("item_type")
.filter(|s| !s.is_empty())
.cloned()
.ok_or_else(|| {
ConversationItemStorageError::StorageError(format!(
"item {fallback_id} missing item_type"
))
})?;
let role = map.get("role").cloned();
let status = map.get("status").cloned();
let content_str = map.get("content").ok_or_else(|| {
ConversationItemStorageError::StorageError(format!(
"item {fallback_id} missing content"
))
})?;
let content =
serde_json::from_str(content_str).map_err(ConversationItemStorageError::SerializationError)?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data_connector/src/redis.rs` around lines 250 - 279, build_item_from_map
currently treats a missing "content" key as Value::Null which hides
corrupted/missing data; change the logic in build_item_from_map so that when
map.get("content") is None it returns
Err(ConversationItemStorageError::StorageError(...)) (similar to how missing
"item_type" is handled), using the fallback_id to construct a clear error
message; keep the existing serde_json::from_str error mapping for
present-but-invalid JSON.

Comment on lines 411 to 467
let mut min = "-inf".to_string();
let mut max = "+inf".to_string();
// Track cursor score + id for post-filtering same-millisecond ties,
// matching the composite (added_at, item_id) cursor of Postgres/Oracle.
let mut cursor_score: Option<f64> = None;
let mut cursor_id: Option<String> = None;

if let Some(after_id) = &params.after {
let score: Option<f64> = conn
.zscore(&key, after_id)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?;
if let Some(s) = score {
cursor_score = Some(s);
cursor_id = Some(after_id.clone());
// Use inclusive bound so we can post-filter ties by item_id.
// Over-fetch slightly to account for items at the cursor's score.
match params.order {
SortOrder::Asc => min = format!("({s}"),
SortOrder::Desc => max = format!("({s}"),
SortOrder::Asc => min = s.to_string(),
SortOrder::Desc => max = s.to_string(),
}
}
}

// Over-fetch to handle same-score ties that need filtering
let fetch_limit = if cursor_score.is_some() {
// Fetch extra to compensate for items we'll filter out at the cursor boundary
(params.limit + 32) as isize
} else {
params.limit as isize
};

let item_ids: Vec<String> = match params.order {
SortOrder::Asc => {
// ZRANGEBYSCORE key min max LIMIT offset count
conn.zrangebyscore_limit(&key, min, max, 0, params.limit as isize)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?
}
SortOrder::Desc => {
// ZREVRANGEBYSCORE key max min LIMIT offset count
conn.zrevrangebyscore_limit(&key, max, min, 0, params.limit as isize)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?
}
SortOrder::Asc => conn
.zrangebyscore_limit(&key, min, max, 0, fetch_limit)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?,
SortOrder::Desc => conn
.zrevrangebyscore_limit(&key, max, min, 0, fetch_limit)
.await
.map_err(|e| ConversationItemStorageError::StorageError(e.to_string()))?,
};

// Post-filter: skip past the cursor item and all same-score predecessors.
// Redis returns same-score members in lexicographic order (ASC) or
// reverse-lex (DESC), so `skip_while` advances past items that appeared
// on the previous page, then `skip(1)` drops the cursor item itself.
let item_ids: Vec<String> = if let (Some(_), Some(ref c_id)) = (cursor_score, &cursor_id) {
item_ids
.into_iter()
.skip_while(|id| id != c_id)
.skip(1)
.take(params.limit)
.collect()
} else {
item_ids.into_iter().take(params.limit).collect()
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the full context around the cursor pagination logic
cat -n data_connector/src/redis.rs | sed -n '380,480p'

Repository: lightseekorg/smg

Length of output: 4652


🏁 Script executed:

# Check for any item expiry/TTL logic in the codebase
rg -i "expire|ttl|delete|remove" data_connector/src/redis.rs -A 2 -B 2

Repository: lightseekorg/smg

Length of output: 2053


🏁 Script executed:

# Look for test cases related to cursor pagination
fd -e rs -e toml | xargs rg -l "cursor|pagination|zrange" | head -5

Repository: lightseekorg/smg

Length of output: 198


🏁 Script executed:

# Check if there are any guards or error handling for missing cursor scenarios elsewhere
rg "skip_while|position|cursor" data_connector/src/redis.rs -B 3 -A 3

Repository: lightseekorg/smg

Length of output: 2080


Guard against missing cursor IDs to avoid empty pages during pagination.

The cursor entry can expire or be deleted between the ZSCORE lookup (line 419) and the ZRANGEBYSCORE query (lines 444–451). If this occurs, the skip_while(|id| id != c_id).skip(1) logic will iterate through the entire result set without finding the cursor ID, exhaust the iterator, and return an empty page even when later items exist. The over-fetch buffer of +32 items compensates for same-score ties but does not address this race condition.

Detect when the cursor is missing and fall back to score-only pagination to ensure continuous pagination across item lifecycles.

Proposed mitigation
-        let item_ids: Vec<String> = if let (Some(_), Some(ref c_id)) = (cursor_score, &cursor_id) {
-            item_ids
-                .into_iter()
-                .skip_while(|id| id != c_id)
-                .skip(1)
-                .take(params.limit)
-                .collect()
-        } else {
-            item_ids.into_iter().take(params.limit).collect()
-        };
+        let item_ids: Vec<String> = if let (Some(_), Some(ref c_id)) = (cursor_score, &cursor_id) {
+            if let Some(pos) = item_ids.iter().position(|id| id == c_id) {
+                item_ids
+                    .into_iter()
+                    .skip(pos + 1)
+                    .take(params.limit)
+                    .collect()
+            } else {
+                // Cursor vanished between ZSCORE and ZRANGEBYSCORE; fall back to score-only paging.
+                item_ids.into_iter().take(params.limit).collect()
+            }
+        } else {
+            item_ids.into_iter().take(params.limit).collect()
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data_connector/src/redis.rs` around lines 411 - 467, The post-filtering
currently assumes the cursor id (cursor_id / params.after) is present in the
fetched item_ids, which races if the cursor member expired and causes empty
pages; update the logic after fetching item_ids (from zrangebyscore_limit /
zrevrangebyscore_limit) to check whether c_id is actually present in the
returned Vec: if c_id is found, keep the existing skip_while(|id| id !=
c_id).skip(1).take(params.limit).collect() behavior, but if c_id is not found,
fall back to score-only pagination by simply taking the first params.limit items
from the fetched set (i.e., item_ids.into_iter().take(params.limit).collect()),
thus preserving pagination when the cursor member is missing.

@slin1237
slin1237 merged commit f023284 into main Feb 22, 2026
37 of 40 checks passed
@slin1237
slin1237 deleted the slin/db-fixes branch February 22, 2026 05:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-connector Data connector crate changes dependencies Dependency updates documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant