Skip to content

[Bug](cloud) Packed file correction can delete a just-recycled rowset's slice: recycle/tmp and formal rowset keys are read in different transactions #69861

Open

Description

@matanper

Search before asking

  • I had searched in the issues and found no similar issues.

Version

4.1.4 (cloud mode, packed files). master is the same: cloud/src/recycler/recycler.cpp:1487 reads the recycle and tmp rowset keys in one transaction (check_recycle_and_tmp_rowset_exists), and :1503 reads the formal rowset key in another (check_rowset_exists).

Description

When the recycler corrects a packed file, it decides for each small file (slice) whether its rowset is still alive:

if (check_recycle_and_tmp_rowset_exists(tablet_id, rowset_id, txn_id, &recycle_exists, &tmp_exists) != 0) ...
...
} else {                       // neither recycle nor tmp key seen
    bool rowset_exists = false;
    if (check_rowset_exists(tablet_id, rowset_id, &rowset_exists, stats) != 0) ...
    if (!rowset_exists) { small_file->set_deleted(true); small_file->set_corrected(true); ... }

The two checks are separate transactions. If a compaction that consumes the rowset commits between them, the first read sees the formal key (no recycle key yet) and the second sees neither the formal key (removed by the commit) nor looks again at the recycle key (just written). The live, just-recycled rowset is taken as gone, its slice is marked deleted, and the packed file can be deleted before the compacted rowset's retention ends. Readers still on the old version (or anything relying on the retention window) then hit missing data.

PR #68153 makes the packed-file checker read within one snapshot; this recycler correction path is separate.

Solution

Read the recycle, tmp and formal rowset keys of a slice in one transaction / snapshot (e.g. one check_rowset_keys(...) that returns all three), so a concurrent compaction commit is seen either entirely before or entirely after.

How to Reproduce?

With packed files enabled, inject a sync point between check_recycle_and_tmp_rowset_exists and check_rowset_exists in packed file correction, commit a compaction whose input includes the slice's rowset there, and observe the slice marked deleted while the rowset's recycle key exists.

Anything Else?

The race window is small, but the result is premature deletion of data still inside its retention window.

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Code of Conduct

Activity

  1. Doris-Breakwater commented on Oct 29, 2026

    @Doris-Breakwater
    Collaborator

    The reported interleaving is supported by the code and warrants high-priority cloud/recycler correctness triage because it can make retained data eligible for premature physical deletion. This is a source-level finding; I have not run the proposed race reproduction or verified an actual missing-data incident. The issue currently has no labels.

    I checked the public 4.1.4 tag (ad35a140c7fd) and upstream master at 2a4191c1dc94. Both contain the split reads. In particular:

    • check_recycle_and_tmp_rowset_exists() creates its own transaction. check_rowset_exists() scans formal rowsets through txn_get(), which creates another transaction for each scan page. The outer transaction in process_single_packed_file() is not passed into either helper. See the 4.1.4 helpers and current master correction path.
    • For the non-versioned metadata write path, compaction puts the input rowset's recycle record and removes its formal metadata in the same transaction, committed by _finish_tablet_job(). Thus the writer is atomic, but the correction reader can combine observations from opposite sides of that commit. See compaction input recycling and commit.

    The failing schedule is:

    1. An eligible, uncorrected slice belongs to a formal rowset; its recycle and tmp keys are absent.
    2. Correction reads recycle/tmp absence at snapshot A. Strictly speaking, this first helper does not read the formal key; that key merely still exists at this point.
    3. Compaction atomically removes the formal rowset and creates its recycle record.
    4. The subsequent formal-rowset scan at snapshot B cannot find that rowset ID. Correction marks the slice deleted=true, corrected=true, and excludes it from the recomputed reference count and remaining bytes.

    Physical deletion is conditional: correction must be due, the slice must not already be corrected/deleted, the packed-metadata update must commit, and the resulting packed-file reference count must reach zero. Other live slices can postpone object deletion, but the incorrect slice accounting remains. With zero references, correction sets RECYCLING, and process_single_packed_file() can delete the object in the same call. Its later KV reread checks packed-file state/count after object deletion; it does not recheck rowset retention. The packed-key conflict retry is not sufficient because the input rowset's formal-to-recycle transition does not itself update that packed key. See the correction and deletion sequence. The ordinary rowset expiration calculation applies the compacted-rowset retention interval, but this orphan-classification path bypasses that check. See retention calculation.

    The proposed shared-transaction approach addresses this interleaving. It must cover recycle/tmp reads and every page of the formal-rowset scan, with the whole existence decision restarted on transaction expiry/read failure. Merely putting the existing helpers behind one wrapper, or setting snapshot=true on independent transactions, would preserve the problem. Keep the packed-metadata write conflict/retry protection and the existing semantics that preserve recycle references without marking them corrected. PR #68153 is still open/unmerged at review time; its checker changes do not modify this recycler correction implementation.

    Suggested validation and remaining evidence:

    • Add a deterministic regression beside recycle_packed_files_respect_recycle_and_tmp_refs in cloud/test/recycler_test.cpp. Pause after recycle/tmp absence, commit the formal-to-recycle transition, then resume correction. Use a packed file whose only remaining reference is the target slice; keep force_immediate_recycle=false, positive rowset retention, and an old enough packed-file creation time to make correction due. Assert that the unexpired recycle key, slice reference/count/bytes, and object survive. Exercise both commit orderings and a multi-page formal scan; also verify genuine orphans still recycle.
    • Please provide the exact deployed MetaService/recycler build SHA and whether versioned metadata reads/writes are enabled. The atomic transition traced above is specifically the non-versioned write path; versioned operation-log handling needs separate coverage before extending the conclusion to that mode.
    • To establish observed impact, attach a sanitized reproducer/test result, before/after PackedFileInfoPB, the matching formal/tmp/recycle metadata with recycle creation time, and effective packed_file_correction_delay_seconds, compacted_rowset_retention_seconds, retention_seconds, and force_immediate_recycle. Correlate compaction commit with packed slice correction status, corrected packed file ref count, and deleted packed file logs. Any claimed reader failure additionally needs the corresponding BE missing-object error and timestamp. A query profile is not needed to establish this metadata race.

    No source code was modified and no fix or regression-test pass is claimed.

    Breakwater-GitHub-Analysis-Slot: slot_3c21b68379d1

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions