Skip to content

fix(pageserver): prevent stack overflow in count_deltas#12904

Open
AesaKamar wants to merge 2 commits into
neondatabase:mainfrom
AesaKamar:test-layer-map-overflow
Open

fix(pageserver): prevent stack overflow in count_deltas#12904
AesaKamar wants to merge 2 commits into
neondatabase:mainfrom
AesaKamar:test-layer-map-overflow

Conversation

@AesaKamar

@AesaKamar AesaKamar commented Jun 2, 2026

Copy link
Copy Markdown

Problem

Evaluating very deep historical delta layer maps in the pageserver (such as during active GC and compaction) can cause a stack overflow in LayerMap::count_deltas due to recursive DFS traversal (resolving #3711).

In #3711, @jcsp noted:

"I don't see any changes in layer_coverage.rs that look like a fix for this... until we have a test that explicitly creates huge numbers of tiny layers and then checks compaction/GC still work properly, the issue might still be here."

Summary of changes

  • Iterative Stack Machine: Converted the recursive DFS traversal of the historical layer map in pageserver/src/tenant/layer_map.rs into an iterative, stack-based algorithm utilizing a heap-allocated task stack. This prevents call stack exhaustion.
  • Regression Test: Added test_count_deltas_stack_overflow in layer_map.rs which verifies that evaluating a stack of 10,000 delta layers completes successfully without crashing. The test runs safely and completes in <0.3s.

P.S. This is another contribution following up on my first PR! I would love any feedback on the implementation.

@AesaKamar AesaKamar force-pushed the test-layer-map-overflow branch from c3a1d22 to 0c2c57e Compare June 2, 2026 16:14
@AesaKamar AesaKamar marked this pull request as ready for review June 2, 2026 16:17
@AesaKamar AesaKamar requested a review from a team as a code owner June 2, 2026 16:17
@AesaKamar AesaKamar requested a review from banks June 2, 2026 16:17

@Dharin-shah Dharin-shah 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.

Hi, I was about to pick this one up and saw your PR, so I read through it instead. Looks right to me, a couple of small questions.

let lr = lsn.start..val.get_lsn_range().start;
if !kr.is_empty() {
let base_count = Self::is_reimage_worthy(val, &key) as usize;
let new_limit = limit.map(|l| l.saturating_sub(base_count));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This changed from l - base_count to l.saturating_sub(base_count). Equivalent here given base_count is 0 or 1 and Some(0) returns early, just checking the saturating was deliberate and not covering an underflow I'm not seeing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hmm, There's no hidden underflow here, (I don't think at least)

Since we early-exit if limit is Some(0),l is always >= 1,, and base_count is at most <= 1. It was just a defensive change to avoid raw subtraction on usize~

}

let frame_idx = frames.len();
frames.push(Frame { max_stacked_deltas: 0 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

frames only grows, it's never truncated, so it ends up holding a frame per internal node for the whole call rather than just the current path. With the bounded limit the real callers pass it stays small, but on the limit: None path it's proportional to the total number of partitions. Intentional, or worth clearing as subtrees finish?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for reviewing. You're right, since this is DFS, we can just pop the frame when the subtree finishes. I've updated it to pop in FinishFrame so the space stays bounded by the path depth O(H) rather than the total number of partitions.

Comment thread pageserver/src/tenant/layer_map.rs Outdated
let tenant_shard_id = TenantShardId::unsharded(TenantId::generate());
let timeline_id = TimelineId::generate();

// Insert 10,000 overlapping delta layers with increasing LSN ranges

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: the comment says 10,000 but the loop is 1..10000 so it's 9999, and they overlap on the key range, not the LSN ranges (those are disjoint). Asserting the exact 9999 is good though, it catches a semantic regression and not just the crash.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, updated the comments to match the actual count and ranges~

@AesaKamar AesaKamar requested a review from Dharin-shah July 2, 2026 12:12
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