Skip to content

feat: keeper trait & sqlite-backed implementation - #582

Open
aldy505 wants to merge 3 commits into
mainfrom
aldy505/feat/ttl-keeper
Open

feat: keeper trait & sqlite-backed implementation#582
aldy505 wants to merge 3 commits into
mainfrom
aldy505/feat/ttl-keeper

Conversation

@aldy505

@aldy505 aldy505 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Internal Slack thread.

This is required for filesystem & S3-compatible API backends. Later, I'll try to integrate this with filesystem backend, and create Postgres-backed keeper.

@aldy505
aldy505 requested a review from lcian August 1, 2026 12:19
@aldy505
aldy505 requested a review from a team as a code owner August 1, 2026 12:19
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.54%. Comparing base (c20acef) to head (56c46f3).
⚠️ Report is 30 commits behind head on main.

Files with missing lines Patch % Lines
objectstore-service/src/keeper/sqlite_backed.rs 82.56% 38 Missing ⚠️
objectstore-service/src/error.rs 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #582      +/-   ##
==========================================
+ Coverage   87.44%   87.54%   +0.10%     
==========================================
  Files          93       95       +2     
  Lines       14753    15811    +1058     
==========================================
+ Hits        12901    13842     +941     
- Misses       1852     1969     +117     
Components Coverage Δ
Rust Backend 92.07% <81.81%> (-0.12%) ⬇️
Rust Client 79.89% <ø> (ø)
Python Client 90.98% <ø> (+1.60%) ⬆️

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread objectstore-service/src/keeper/sqlite_backed.rs
Comment thread objectstore-service/src/keeper/sqlite_backed.rs Outdated
.read_only(true)
.disable_statement_logging(),
)
.await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Read-only WAL pool setup fails

High Severity

create_sqlite_pool opens the read pool with journal_mode(Wal) and read_only(true) before the write pool can switch the database to WAL. Changing journal mode needs write access, so SqliteBackedKeeper::new is likely to fail when creating or opening a non-WAL database. In-memory tests bypass this path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 076740d. Configure here.

Comment on lines +92 to +97
let expiration_duration: Option<i64> = expiration_policy.expires_in().and_then(|x| {
x.as_secs()
.try_into()
.map_err(|_| Error::generic("expiration duration exceeds i64::MAX"))
.ok()
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: A Duration exceeding i64::MAX is silently stored as NULL in the database, creating an inconsistent state for objects with an expiration policy.
Severity: LOW

Suggested Fix

Instead of using .ok() to discard the error, propagate the Result of the try_into() conversion. This will cause the operation to fail explicitly when an out-of-range duration is provided, preventing the insertion of records with inconsistent state. This ensures that any object with an expiration policy also has a valid expiration time stored.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: objectstore-service/src/keeper/sqlite_backed.rs#L92-L97

Potential issue: The conversion of an `ExpirationPolicy` duration from `u64` seconds to
an `i64` for database storage uses `.ok()`, which silently discards overflow errors. If
a `Duration` greater than `i64::MAX` (approximately 292 billion years) is provided, the
conversion fails and results in `None`. This `None` value is then persisted as `NULL`
for the `duration` and `expires_at` columns. This creates an inconsistent database state
where an object has an expiration policy (TTL/TTI) but no corresponding expiration data,
which could lead to unexpected retention behavior.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 56c46f3. Configure here.

tk.keeper.mark_accessed(&id).await.unwrap();

let row = tk.fetch_row(&id).await.unwrap();
assert_eq!(row.expires_at, Some(now + 60));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Flaky second-boundary expiry assertion

Low Severity

mark_accessed_tti_without_expires_at_sets_it captures now, then asserts expires_at equals exactly now + 60. mark_accessed recomputes time independently in whole seconds, so a second boundary between those calls makes the assertion fail even when behavior is correct.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 56c46f3. Configure here.

@jan-auer jan-auer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you! This is a first review pass with some questions.

The design looks good, specifically:

  • It's great to introduce a trait so we can have different implementations of this via config.
  • The overall keeper interface is simple and doesn't assume too much about object lifecycle (though see my comment on TTI below).
  • It makes sense that the backend "owns" the instance of the keeper.

Some question to the overall design:

  1. Who is responsible to scan for deleted objects and drive deletion and what will the interface for this look like?
    • If it is the keeper, how does it tell the backend to delete?
    • If it is the backend, how does it use the keeper interface to scan? There's no method to iterate objects that are ready for deletion
  2. If the keeper database gets corrupted, deleted, or the keeper is swapped out, we lose all information on objects and GC for those objects will no longer happen. Doesn't have to be solved immediately, but do you already have thoughts on this?
  3. How will the keeper ensure that on concurrent writes to the same key that keeper's entry corresponds to what is stored in the backend?
    • Two requests PUT at the same time with different expires_at. Only one of them will win, keeper must end up with the same expiry time.
    • One request PUTs and one DELETEs at the same time. One of them wins, and the keeper must match.

When calling keep, ensure to do so before writing the object. Otherwise, we could end up with a persisted object but without keeper entry.

sqlite can only have a single writer attached to a database file at any time. This means when sqlite is used, one cannot run multiple objectstore instances. This is an important restriction we should add to some doc comment and later to the config that exposes this.

percent-encoding = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["charset", "http2", "system-proxy", "native-tls-no-alpn"] }
reqwest = { workspace = true, features = [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's undo these formatting changes to keep dependencies in a single line. Same for other Cargo.toml files.

"set-header",
"trace",
] }
sqlx.workspace = true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks like this is unused in the server.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is me toying around with the sqlx migrate stuff, to have compile time checks for the query.

sentry = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ideally, leave the workspace dependency without most features and include the required features here. This way, when we use conditional compilation of certain sub-crates we automatically get the smallest possible set of features.


/// Object retention keeper trait.
#[async_trait::async_trait]
pub trait Keeper: Send + Sync {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we find a more descriptive name for this? Keeper is a nice and short name, but it lacks context on what this is for.

Intuitively, what we're building is GC, so I'm throwing that in as a suggestion.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I took Zookeeper and ClickHouse Keeper as a reference for this 😄

async fn keep(&self, id: &ObjectId, expiration_policy: ExpirationPolicy) -> Result<()>;

/// Remove is the final step in the object retention lifecycle.
/// It is called by a cleanup worker when the object is no longer needed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Either this, or when the object is deleted per user request (or as part of tiered storage cleanup, but that's the same method).


/// Marks an object as accessed. For `expiration_policy` of `TimeToIdle`, this will
/// extend the object retention.
async fn mark_accessed(&self, id: &ObjectId) -> Result<()>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we instead make this more of an "update" method and pass an explicit new expiration time?

I'm currently working on refactoring TTI to centralize its logic. Right now, backends have to handle this all internally, which leads to multiple problems. The biggest one is that the eviction timestamp can go out of sync with the actual object.

We can fix that by passing an explicit expiration time around.

/// Unix timestamp (seconds) when the row was created.
pub created_at: i64,
/// Unix timestamp (seconds) when the object expires, if applicable.
pub expires_at: Option<i64>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: Where possible, let's adopt the same terminology we also use in Metadata, such as time_expires, etc.

@aldy505

aldy505 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author
  • Who is responsible to scan for deleted objects and drive deletion and what will the interface for this look like?
    • If it is the keeper, how does it tell the backend to delete?
    • If it is the backend, how does it use the keeper interface to scan? There's no method to iterate objects that are ready for deletion

I'm thinking of a separate cleanup process for this. Therefore only the keeper is the one who's responsible.

  • If the keeper database gets corrupted, deleted, or the keeper is swapped out, we lose all information on objects and GC for those objects will no longer happen. Doesn't have to be solved immediately, but do you already have thoughts on this?

I haven't think this through. On my current proposal, I know that there will be lots of orphan objects, and there's no way to figure out whether it should be managed by the keeper or not.

One thing that came across my mind just now is to append it on the sidecar metadata file (that we've talked about on Slack). I'm thinking it only for a last resort recovery option.

sqlite can only have a single writer attached to a database file at any time. This means when sqlite is used, one cannot run multiple objectstore instances. This is an important restriction we should add to some doc comment and later to the config that exposes this.

Oh yes, and I'm considering to add Postgres as another keeper backend.

@aldy505

aldy505 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author
  1. How will the keeper ensure that on concurrent writes to the same key that keeper's entry corresponds to what is stored in the backend?

    • Two requests PUT at the same time with different expires_at. Only one of them will win, keeper must end up with the same expiry time.

    • One request PUTs and one DELETEs at the same time. One of them wins, and the keeper must match.

Yes I'm aware of this. Since sqlite is a single writer, I would trust whoever enters objectstore first.

@jan-auer

jan-auer commented Aug 5, 2026

Copy link
Copy Markdown
Member

Since sqlite is a single writer, I would trust whoever enters objectstore first.

Sqlite is a single writer, but objectstore allows non-blocking concurrent requests on the same object. Since these requests consist of several sequential operations, there can be races like TOCTOU and lost updates. A request that comes in first may not be the first to finish, and there can be any form of interleaving.

In principle, there are these options:

  • Synchronize and block. This is what we do in the in-memory backend for testing. We do not do this in other backends because we lack the primitives for synchronization across multiple instances of objectstore. Also, this would further increase latency.
  • Check and fail if there is an operation ongoing. Again, we're lacking the primitives here in many cases.
  • Update optimistically. Treat concurrent operations like they were serialized and ensure there is consistent outcome. This requires a form of atomic updates or CAS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants