Skip to content

chore: Rework Reach Calculation to Use Less CPU and Memory - BED-7379#2364

Merged
zinic merged 1 commit intomainfrom
BED-7379
Feb 20, 2026
Merged

chore: Rework Reach Calculation to Use Less CPU and Memory - BED-7379#2364
zinic merged 1 commit intomainfrom
BED-7379

Conversation

@zinic
Copy link
Copy Markdown
Contributor

@zinic zinic commented Feb 12, 2026

Description

Reach calculation for AD Group and LocalGroup recursive membership currently merges all memberships into a single bitmap. This operation is both CPU and memory intensive. Usage of reach calculation specifically in CalculateCrossProductNodeSets where this hotspot was located does not require the merge step as all operations performed are commutative.

This changeset uses a newer release of dawgs that contains updates to the reach cache to avoid the above caveats. The refactored CalculateCrossProductNodeSets no longer merges reach membership bitmaps and instead uses commutative operations and an iterative method for validating membership.

Motivation and Context

Resolves BED-7379

Lower CPU and memory consumption. Faster. Better.

How Has This Been Tested?

Existing tests both unit and integration cover the refactored codepaths. Additional testing was done in dawgs for version v0.4.8.

Types of changes

  • Chore (a change that does not modify the application functionality)

Checklist:

Summary by CodeRabbit

  • Chores

    • Updated an external dependency to the latest patch version.
  • Improvements

    • Cross-product aggregation enhanced to handle multiple per-node reachability slices for more accurate results.
    • Cache logging now reports size, hits, and misses for clearer cache insights.
    • Excluded shortcut groups are now materialized as iterative lists, improving exclusion handling.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Bumps github.com/specterops/dawgs to v0.4.8, refactors cross-product node-set calculation to handle multiple per-entity reachability bitmaps, adjusts PostCanRDP cache logging fields/types, and adds a materialized ExcludedShortcutGroupsSlice to local group data.

Changes

Cohort / File(s) Summary
Dependency Update
go.mod
Bumped github.com/specterops/dawgs from v0.4.7 to v0.4.8.
Bitmap Processing Refactor
packages/go/analysis/ad/ad.go
Reworked cross-product node-set calculation to support multiple per-entity/per-node reachability bitmaps: unrolledSets becomes [][]cardinality.Duplex[uint64], check/set types switched to commutative duplex/combination types, loops updated to accumulate and combine bitmap slices with exclusion checks.
Local Groups Logging
packages/go/analysis/ad/local_groups.go
Changed PostCanRDP logging to emit cache_size (Size()), cache_hits (Hits()), and cache_misses (Misses()), switching logged numeric types to Int64-based fields.
Excluded Shortcut Materialization
packages/go/analysis/ad/post.go
Added ExcludedShortcutGroupsSlice []uint64 to LocalGroupData; populate it from ExcludedShortcutGroups.Slice() in FetchLocalGroupData. Removed some per-CanRDP population of ExcludedShortcutGroups in FetchCanRDPData.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • rvazarkar
  • superlinkx
  • elikmiller
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main objective: refactoring reach calculation to reduce CPU and memory usage, with the associated ticket BED-7379.
Description check ✅ Passed The pull request description covers the required template sections: Description, Motivation and Context, How Has This Been Tested, Types of changes, and a completed Checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch BED-7379

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@zinic zinic force-pushed the BED-7379 branch 2 times, most recently from bacc8ba to a4bb4d9 Compare February 20, 2026 16:57
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/go/analysis/ad/ad.go (2)

542-558: Append to entityReachBitmaps precedes exclusion guard — reorder for clarity

Line 543 appends entityReachSets to entityReachBitmaps unconditionally, even though lines 545–558 may immediately set nodeExcluded = true, causing the entire slice to be discarded at line 567. The code is functionally correct, but reordering — check exclusion first, append only when safe — eliminates the unnecessary allocation on the excluded path and makes the intent clearer.

♻️ Proposed reorder
 entityReachSets := localGroupData.GroupMembershipCache.ReachSliceOfComponentContainingMember(entityID, graph.DirectionInbound)
-entityReachBitmaps = append(entityReachBitmaps, entityReachSets...)
 
 for _, entityReachSet := range entityReachSets {
     if entityReachSet.Cardinality() > 0 {
         for _, excludedGroup := range localGroupData.ExcludedShortcutGroupsSlice {
             if entityReachSet.Contains(excludedGroup) {
                 nodeExcluded = true
                 break
             }
         }
     }
 
     if nodeExcluded {
         break
     }
 }
+
+if !nodeExcluded {
+    entityReachBitmaps = append(entityReachBitmaps, entityReachSets...)
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/go/analysis/ad/ad.go` around lines 542 - 558, The code currently
appends entityReachSets to entityReachBitmaps before checking exclusion, causing
unnecessary allocations when nodeExcluded becomes true; change the order in the
loop that calls
localGroupData.GroupMembershipCache.ReachSliceOfComponentContainingMember so
that you first iterate entityReachSets and test each entityReachSet against
localGroupData.ExcludedShortcutGroupsSlice (setting nodeExcluded if any match)
and only append entityReachSets to entityReachBitmaps when nodeExcluded is
false; update references to entityReachSets, entityReachBitmaps, nodeExcluded,
and ExcludedShortcutGroupsSlice accordingly to ensure the early-exit exclusion
logic happens before appending.

509-509: unrolledSets[0] is allocated and populated but never read

Every non-excluded nodeset appends its entityReachBitmaps to unrolledSets (line 568), producing unrolledSets[0], unrolledSets[1], unrolledSets[2:]. However, the checkSet population at lines 588–592 starts at index [1] and [2:]unrolledSets[0] is never accessed. The first nodeset's reach is handled exclusively via firstDegreeSets[0] and GroupMembershipCache.OrReach (line 599), making the first slot's bitmap slice an always-wasted allocation. This directly undercuts the PR's memory-reduction goals.

Consider skipping the append to unrolledSets for the first non-excluded nodeset:

♻️ Proposed refactor
-		if !nodeExcluded {
-			unrolledSets = append(unrolledSets, entityReachBitmaps)
-			firstDegreeSets = append(firstDegreeSets, firstDegreeSet)
-		}
+		if !nodeExcluded {
+			// Only accumulate reach bitmaps for sets beyond the first;
+			// the first set is handled via firstDegreeSets[0] + OrReach.
+			if len(firstDegreeSets) > 0 {
+				unrolledSets = append(unrolledSets, entityReachBitmaps)
+			}
+			firstDegreeSets = append(firstDegreeSets, firstDegreeSet)
+		}

Update the downstream index references accordingly (unrolledSets[0] → first non-first set, unrolledSets[1:] → remainder).

Also applies to: 568-568, 588-592

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/go/analysis/ad/ad.go` at line 509, The allocation for unrolledSets
currently includes the first non-excluded nodeset but that slot is never read;
change the logic that appends entityReachBitmaps so it skips appending for the
very first non-excluded nodeset (i.e., only append subsequent nodesets into
unrolledSets) and then update all downstream uses to account for the missing
initial slot—replace uses that assumed unrolledSets[0] with the first nodeset's
data from firstDegreeSets[0] / GroupMembershipCache.OrReach and shift index math
where checkSet population and any loops use unrolledSets (e.g., the code
building checkSet and the loop that reads unrolledSets[1:] should instead
operate on the appended slice representing subsequent nodesets). Ensure
references to unrolledSets, entityReachBitmaps, firstDegreeSets, and
GroupMembershipCache.OrReach are updated consistently so no unused first slot is
allocated.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/go/analysis/ad/ad.go`:
- Around line 542-558: The code currently appends entityReachSets to
entityReachBitmaps before checking exclusion, causing unnecessary allocations
when nodeExcluded becomes true; change the order in the loop that calls
localGroupData.GroupMembershipCache.ReachSliceOfComponentContainingMember so
that you first iterate entityReachSets and test each entityReachSet against
localGroupData.ExcludedShortcutGroupsSlice (setting nodeExcluded if any match)
and only append entityReachSets to entityReachBitmaps when nodeExcluded is
false; update references to entityReachSets, entityReachBitmaps, nodeExcluded,
and ExcludedShortcutGroupsSlice accordingly to ensure the early-exit exclusion
logic happens before appending.
- Line 509: The allocation for unrolledSets currently includes the first
non-excluded nodeset but that slot is never read; change the logic that appends
entityReachBitmaps so it skips appending for the very first non-excluded nodeset
(i.e., only append subsequent nodesets into unrolledSets) and then update all
downstream uses to account for the missing initial slot—replace uses that
assumed unrolledSets[0] with the first nodeset's data from firstDegreeSets[0] /
GroupMembershipCache.OrReach and shift index math where checkSet population and
any loops use unrolledSets (e.g., the code building checkSet and the loop that
reads unrolledSets[1:] should instead operate on the appended slice representing
subsequent nodesets). Ensure references to unrolledSets, entityReachBitmaps,
firstDegreeSets, and GroupMembershipCache.OrReach are updated consistently so no
unused first slot is allocated.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/go/analysis/ad/ad.go (1)

568-569: unrolledSets[0] is built but never consumed — the per-entity appends for the first nodeSlice are wasted work.

After this loop completes, unrolledSets is only accessed at indices [1] (line 588) and [2:] (lines 590–592). The element at index 0, along with all entityReachBitmaps = append(entityReachBitmaps, entityReachSets...) accumulations made for entities in nodeSlices[0], are never read. The ReachSliceOfComponentContainingMember calls for nodeSlices[0] are still necessary for the exclusion check, but the subsequent append to entityReachBitmaps and the resulting unrolledSets[0] entry are pure overhead.

♻️ Proposed refactor: skip accumulation into `entityReachBitmaps` for `nodeSlices[0]`
-    for _, nodeSlice := range nodeSlices {
+    for nodeSliceIdx, nodeSlice := range nodeSlices {
         var (
             nodeExcluded = false

             firstDegreeSet     = cardinality.NewBitmap64()
             entityReachBitmap  = cardinality.NewBitmap64()
-            entityReachBitmaps = []cardinality.Duplex[uint64]{entityReachBitmap}
+            entityReachBitmaps = []cardinality.Duplex[uint64]{entityReachBitmap}  // only used for nodeSliceIdx > 0
         )

         for _, entity := range nodeSlice {
             entityID := entity.ID.Uint64()

             firstDegreeSet.Add(entityID)
             entityReachBitmap.Add(entityID)

             if entity.Kinds.ContainsOneOf(ad.Group, ad.LocalGroup) {
                 if localGroupData.ExcludedShortcutGroups.Contains(entityID) {
                     nodeExcluded = true
                 } else {
                     entityReachSets := localGroupData.GroupMembershipCache.ReachSliceOfComponentContainingMember(entityID, graph.DirectionInbound)
-                    entityReachBitmaps = append(entityReachBitmaps, entityReachSets...)
+                    if nodeSliceIdx > 0 {
+                        entityReachBitmaps = append(entityReachBitmaps, entityReachSets...)
+                    }
                     // exclusion check still runs for all nodeSlices
                     for _, entityReachSet := range entityReachSets {
                         ...
                     }
                 }
             }
             ...
         }

         if !nodeExcluded {
-            unrolledSets = append(unrolledSets, entityReachBitmaps)
+            if nodeSliceIdx > 0 {
+                unrolledSets = append(unrolledSets, entityReachBitmaps)
+            }
             firstDegreeSets = append(firstDegreeSets, firstDegreeSet)
         }
     }

Note: if unrolledSets and firstDegreeSets are decoupled, the early-exit len(firstDegreeSets) checks (lines 574–585) and the unrolledSets[1] / unrolledSets[2:] index math must be adjusted accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/go/analysis/ad/ad.go` around lines 568 - 569, The loop currently
builds and appends per-entity accumulations into entityReachBitmaps for every
nodeSlice, but the element at index 0 of unrolledSets is never read; update the
loop so that when iterating nodeSlices[0] you still call
ReachSliceOfComponentContainingMember for the exclusion check but skip appending
to entityReachBitmaps and do not append an entry to unrolledSets for index 0;
keep building firstDegreeSets as before (or else adjust the later
len(firstDegreeSets) checks) and ensure subsequent code still reads
unrolledSets[1] and unrolledSets[2:] as before. Specifically modify the code
around the append(unrolledSets, entityReachBitmaps) / append(firstDegreeSets,
firstDegreeSet) calls to conditionally skip the entityReachBitmaps accumulation
and unrolledSets append when handling nodeSlices[0], preserving all exclusion
logic that uses ReachSliceOfComponentContainingMember.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/go/analysis/ad/post.go`:
- Line 659: FetchCanRDPData overwrites LocalGroupData.ExcludedShortcutGroups
without updating ExcludedShortcutGroupsSlice, causing divergence used by
CalculateCrossProductNodeSets; fix by ensuring both are updated atomically. In
FetchCanRDPData (the code that calls FetchAuthUsersAndEveryoneGroups and assigns
to LocalGroupData.ExcludedShortcutGroups) also set
LocalGroupData.ExcludedShortcutGroupsSlice =
LocalGroupData.ExcludedShortcutGroups.Slice(), or better add a LocalGroupData
method like SetExcludedShortcutGroups(bitmap) that assigns both
ExcludedShortcutGroups and ExcludedShortcutGroupsSlice, then replace the direct
assignments in FetchLocalGroupData and FetchCanRDPData to call
SetExcludedShortcutGroups(...) so the bitmap and slice remain in sync.

---

Nitpick comments:
In `@packages/go/analysis/ad/ad.go`:
- Around line 568-569: The loop currently builds and appends per-entity
accumulations into entityReachBitmaps for every nodeSlice, but the element at
index 0 of unrolledSets is never read; update the loop so that when iterating
nodeSlices[0] you still call ReachSliceOfComponentContainingMember for the
exclusion check but skip appending to entityReachBitmaps and do not append an
entry to unrolledSets for index 0; keep building firstDegreeSets as before (or
else adjust the later len(firstDegreeSets) checks) and ensure subsequent code
still reads unrolledSets[1] and unrolledSets[2:] as before. Specifically modify
the code around the append(unrolledSets, entityReachBitmaps) /
append(firstDegreeSets, firstDegreeSet) calls to conditionally skip the
entityReachBitmaps accumulation and unrolledSets append when handling
nodeSlices[0], preserving all exclusion logic that uses
ReachSliceOfComponentContainingMember.

Comment thread packages/go/analysis/ad/post.go
Copy link
Copy Markdown
Contributor

@urangel urangel left a comment

Choose a reason for hiding this comment

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

Looks equivalent to me! The nested for loops in CalculateCrossProductNodeSets are getting pretty dense and could use some breakout and/or comments imo but they were fairly dense already before this refactor

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/go/analysis/ad/ad.go`:
- Around line 540-566: The reach-set exclusion loop in
CalculateCrossProductNodeSets assumes LocalGroupData.ExcludedShortcutGroupsSlice
is populated; add a defensive guard at the start of
CalculateCrossProductNodeSets that detects when
localGroupData.ExcludedShortcutGroupsSlice is nil or empty while
localGroupData.ExcludedShortcutGroups is non-empty and then populates
ExcludedShortcutGroupsSlice from ExcludedShortcutGroups (e.g., iterate the
set/map/bitmap in ExcludedShortcutGroups and append keys/IDs into
ExcludedShortcutGroupsSlice) so the subsequent loop over
ExcludedShortcutGroupsSlice behaves correctly even if LocalGroupData was
constructed outside FetchLocalGroupData.

Comment thread packages/go/analysis/ad/ad.go
@zinic zinic merged commit e06e09c into main Feb 20, 2026
14 checks passed
@zinic zinic deleted the BED-7379 branch February 20, 2026 21:13
@github-actions github-actions Bot locked and limited conversation to collaborators Feb 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants