Repository navigation
Retention deletes the wrong object prefix for named tenants (cross-tenant data loss) #1812
Description
Summary
Retention deletes objects under the wrong prefix for named tenants. It builds the delete path as {stream_name}/{date} and never inserts the tenant id, even though tenant data is stored at {tenant}/{stream_name}/{date}/....
Two consequences:
- Retention is ineffective for the named tenant — its data is never deleted and object storage grows without bound. Because
remove_manifest_from_snapshotruns before the delete, the manifests are already gone, leaving the surviving objects unreferenced and unqueryable. - Cross-tenant data loss — if a same-named stream exists under the default tenant, its
{stream_name}/date=...directory at the bucket root is deleted instead.
Affected
main @ a0b1136 (v3.2.4). Multi-tenant deployments using retention; single-tenant (tenant_id = None) deployments are unaffected.
Evidence
src/storage/retention.rs:253-261 (inside mod action::delete) builds a tenant-less path:
for date in dates_to_delete {
let path = RelativePathBuf::from_iter([&stream_name, &date]);
delete_tasks.push(async move {
PARSEABLE
.storage
.get_object_store()
.delete_prefix(&path, tenant_id)
.await
});
}delete_prefix never prepends the tenant (s3.rs:1053-1061, localfs.rs:417-435, gcs.rs:804-812, azure_blob.rs:822-830):
async fn delete_prefix(&self, path: &RelativePath, tenant_id: &Option<String>) -> ... {
self._delete_prefix(path.as_ref(), tenant_id).await?;
...
}But data is written tenant-qualified by stream_relative_path (src/storage/object_storage.rs:1377-1383):
if let Some(tenant) = tenant_id
&& !tenant.eq(DEFAULT_TENANT)
{
format!("{tenant}/{stream_name}/{file_suffix}")
} else {
format!("{stream_name}/{file_suffix}")
}For reference, delete_stream does prepend the tenant (s3.rs:1123-1136), so the omission in the retention path is an inconsistency rather than an intentional design.
Root cause: object paths are constructed ad hoc at each call site instead of via a single tenant-aware path builder. On S3, list_dates returns tenant-relative dates (it strips the {tenant}/{stream}/ prefix), so retention discovers the right dates but deletes a bucket-root path.
Note: on LocalFS/GCS/Azure,
list_datesitself also ignores the tenant (tracked separately), so retention there lists the default tenant's dates first and then deletes the default tenant's data.
Reproduction
I reproduced this at the LocalFS backend layer, which executes the exact call the retention delete makes, without needing the full server bootstrap.
Regression test
Add this module to src/storage/localfs.rs. It creates a temp store containing tenant t1's stream and the default tenant's same-named stream, then asserts tenant-scoped listing and deletion.
#[cfg(test)]
mod tenant_path_tests {
//! Regression tests for tenant-scoped object paths.
//! Reproduces retention deleting the wrong prefix, and list APIs ignoring
//! the tenant, at the LocalFS backend layer.
use super::*;
fn make_dir(root: &Path, rel: &str) {
std::fs::create_dir_all(root.join(rel)).unwrap();
}
/// `list_dates` must list the requested tenant's prefix, not the bucket root.
#[tokio::test]
async fn list_dates_must_be_tenant_scoped() {
let tmp = temp_dir::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
// Same-named stream under two tenants, with distinguishable partitions.
make_dir(&root, "t1/logs/date=2024-01-01");
make_dir(&root, "logs/date=2024-03-03");
let store = LocalFS::new(root.clone());
let dates = store
.list_dates("logs", &Some("t1".to_owned()))
.await
.unwrap();
assert_eq!(
dates,
vec!["date=2024-01-01".to_owned()],
"list_dates for tenant t1 returned {dates:?}; it is listing the \
tenant-less prefix"
);
}
/// A retention delete for tenant `t1` must not touch the default tenant's
/// same-named stream.
#[tokio::test]
async fn retention_delete_must_not_touch_other_tenants() {
let tmp = temp_dir::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
make_dir(&root, "t1/logs/date=2024-01-01");
make_dir(&root, "logs/date=2024-01-01");
let store = LocalFS::new(root.clone());
// Exact path construction from `src/storage/retention.rs` (action::delete).
let stream_name = "logs".to_owned();
let date = "date=2024-01-01".to_owned();
let path = RelativePathBuf::from_iter([&stream_name, &date]);
// Retention runs for tenant `t1`.
store
.delete_prefix(&path, &Some("t1".to_owned()))
.await
.unwrap();
assert!(
root.join("t1/logs/date=2024-01-01").exists(),
"tenant t1's own data should still be handled correctly"
);
assert!(
root.join("logs/date=2024-01-01").exists(),
"BUG: a retention run for tenant t1 deleted the DEFAULT tenant's \
logs/date=2024-01-01"
);
}
}Observed result (on current main)
Both tests fail:
running 2 tests
test storage::localfs::tenant_path_tests::retention_delete_must_not_touch_other_tenants ... FAILED
test storage::localfs::tenant_path_tests::list_dates_must_be_tenant_scoped ... FAILED
---- retention_delete_must_not_touch_other_tenants ----
panicked at src/storage/localfs.rs:
BUG: a retention run for tenant t1 deleted the DEFAULT tenant's logs/date=2024-01-01
---- list_dates_must_be_tenant_scoped ----
assertion `left == right` failed: list_dates for tenant t1 returned
["date=2024-03-03"]; it is listing the tenant-less prefix
left: ["date=2024-03-03"]
right: ["date=2024-01-01"]
test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 513 filtered out
This shows both halves of the failure: (1) listing for t1 returns the default tenant's dates, and (2) the retention-style delete for t1 removes the default tenant's logs/date=2024-01-01 while leaving t1's data intact.
Suggested fix
Introduce a single tenant-aware path helper and use it for all list/delete/upload paths:
pub fn stream_prefix(stream: &str, tenant_id: &Option<String>) -> RelativePathBuf {
match tenant_id {
Some(t) if !t.eq(DEFAULT_TENANT) => RelativePathBuf::from_iter([t, stream]),
_ => RelativePathBuf::from(stream),
}
}Then in retention.rs:
let path = stream_prefix(&stream_name, tenant_id).join(&date);The same helper should replace the tenant-blind prefixes in the LocalFS/GCS/Azure list_dates/list_hours/list_minutes implementations.
Acceptance criteria
- For a named tenant, retention deletes only that tenant's
date=...data. - Default-tenant data with a same-named stream is never touched.
- Behavior is consistent across S3, GCS, Azure, and LocalFS.
- The regression tests above pass (they currently fail).
-
delete_prefixpaths are tenant-qualified for all callers.
This issue was identified and characterized with help from DeepSeek V4 Flash Vision Exp.
Activity
nitisht commentedon Oct 7, 2026
Thanks for reporting @SegFaultAX - we'll take a look ASAP
nikhilsinhaparseable commentedon Oct 7, 2026
@SegFaultAX the bug is valid and is on the critical path for multi-tenant deployments, we will fix it as part of the next release
SegFaultAX commentedon Oct 7, 2026
Great, thanks for following up so quickly! I have a patch I'd be happy to supply, but it's quite aggressive about centralizing path construction. I can pare it back to something much smaller, submit it as is, or just wait for ya'll to submit. Either way, happy this was caught early. :)
nikhilsinhaparseable commentedon Oct 7, 2026
sure, you can submit a PR, will be glad to review and get it merged ASAP
SegFaultAX commentedon Oct 7, 2026
Great. Before I do, I did not see in your code of conduct whether or not you're accepting of AI-assisted pull requests. If you're not, then my PR will not be within guidelines, and I would prefer not to waste your time on community-violating changes.
SegFaultAX commentedon Oct 7, 2026
I went ahead and opened the PR. If it's non-conforming per your COC, please feel free to close!