Skip to content

Retention deletes the wrong object prefix for named tenants (cross-tenant data loss) #1812

Open

Description

@SegFaultAX

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_snapshot runs 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_dates itself 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_prefix paths are tenant-qualified for all callers.

This issue was identified and characterized with help from DeepSeek V4 Flash Vision Exp.

Activity

  1. nitisht commented on Oct 7, 2026

    @nitisht
    Member

    Thanks for reporting @SegFaultAX - we'll take a look ASAP

  2. nikhilsinhaparseable commented on Oct 7, 2026

    @nikhilsinhaparseable
    Member

    @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

  3. SegFaultAX commented on Oct 7, 2026

    @SegFaultAX
    Author

    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. :)

  4. nikhilsinhaparseable commented on Oct 7, 2026

    @nikhilsinhaparseable
    Member

    sure, you can submit a PR, will be glad to review and get it merged ASAP

  5. SegFaultAX commented on Oct 7, 2026

    @SegFaultAX
    Author

    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.

  6. SegFaultAX commented on Oct 7, 2026

    @SegFaultAX
    Author

    I went ahead and opened the PR. If it's non-conforming per your COC, please feel free to close!

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

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions