Skip to content

Fix interaction retention never running on large channels - #13

Closed
stphnlee wants to merge 1 commit into
developfrom
fix-rockcleanup-interaction-retention
Closed

stphnlee wants to merge 1 commit into
developfrom
fix-rockcleanup-interaction-retention

Conversation

@stphnlee

Copy link
Copy Markdown

Problem

RockCleanup's interaction retention silently stops working once a channel gets large, so InteractionChannel.RetentionDuration is never enforced.

CleanupOldInteractions accumulates every expiring InteractionSessionId into a List<int>, filtering each candidate with List<int>.Contains against the same list it is appending to:

var interactionSessionIdsOfDeletedInteractions = new List<int>();
...
    .Distinct()
    .ToList()
    .Where( i => !interactionSessionIdsOfDeletedInteractions.Contains( i ) );

interactionSessionIdsOfDeletedInteractions.AddRange( interactionSessionIdsForInteractionChannel );

Contains is a linear scan, so the accumulation is O(n²). It also sits before the BulkDeleteInChunks call, so when it doesn't finish, nothing is deleted at all. RunCleanupTask catches the resulting exception into the job result list rather than failing loudly, so the job reports as having run.

We hit this on a channel that reached 96M rows despite a 60 day retention — roughly 94M distinct expiring session ids, which is on the order of 4×10¹⁵ comparisons, plus a ~380MB List<int> materialized before any delete. The channel had been accumulating since an upgrade six months earlier with no visible failure.

CleanupUnusedInteractionSessions( List<int> ) has two further problems:

  • for ( int x = 0; x < interactionSessionIds.Count / 1000; x++ ) — integer division drops the final partial chunk on every run, and skips the work entirely when there are fewer than 1000 ids.
  • interactionSessionIds.Skip( x * 1000 ).Take( 1000 ) re-enumerates the list from the start on each iteration, giving the same O(n²) behaviour over the id list.

Changes

  • Accumulator is a HashSet<int>, making the duplicate check O(1).
  • UnionWith streams query results into the set instead of ToList()-ing every id and then filtering, dropping one full materialization of the id list.
  • Chunk count rounds up, so the last partial chunk is processed.
  • Chunks are taken with GetRange (indexed) rather than Skip.

No behavioural change to what gets deleted — the resulting set of session ids is the same, and the parameterless CleanupUnusedInteractionSessions() overload is untouched.

Testing

⚠️ Not compiled. Authored on macOS; this targets .NET Framework 4.7.2 with an old-style csproj and needs msbuild on Windows. Please build before merging.

Worth exercising CleanupOldInteractions against a channel with a retention duration and a non-multiple-of-1000 number of expiring sessions, which is the case the integer division was silently skipping.

Upstream

Both bugs are present in upstream Rock, not SECC customizations — they will return on each upgrade until fixed there. Worth reporting to Spark.

CleanupOldInteractions accumulated every expiring InteractionSessionId into a
List<int>, filtering each candidate with List<int>.Contains against the same
list it was appending to. That is O(n^2), and it runs before the
BulkDeleteInChunks call, so on a channel with tens of millions of expiring
interactions the delete was never reached. RunCleanupTask catches the resulting
exception into the job result list, so the channel's RetentionDuration was
silently never enforced. Observed on a channel that reached 96M rows despite a
60 day retention.

Switch the accumulator to a HashSet so the duplicate check is O(1), and use
UnionWith to stream the query results into it instead of materializing every
id with ToList() before filtering.

CleanupUnusedInteractionSessions had two further problems:

- The loop bound used integer division ( Count / 1000 ), dropping the final
  partial chunk on every run and skipping the work entirely when there were
  fewer than 1000 ids.
- Chunks were taken with Skip( x * 1000 ), which re-enumerates the list from
  the start on each iteration, giving the same O(n^2) behaviour.

Round the chunk count up and index into the list with GetRange.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 29, 2026 12:39

Copilot AI 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.

Pull request overview

This PR fixes interaction retention cleanup becoming effectively non-functional on very large interaction channels by removing O(n²) accumulation patterns and correcting chunk-processing logic so deletions reliably run to completion.

Changes:

  • Replaces the accumulating List<int> of expiring interaction session IDs with a HashSet<int> and streams IDs into it via UnionWith to avoid O(n²) Contains checks and extra materialization.
  • Fixes session cleanup chunking to process the final partial chunk (and any <1000 remainder) by rounding up the chunk count.
  • Improves chunk extraction performance by using indexed GetRange instead of repeated Skip(...).
Suppressed comments (1)

Rock/Jobs/RockCleanup.cs:1290

  • Variable names interactionSessionsIdsToKeep / interactionSessionsIdsToRemove read like a pluralization typo ("SessionsIds"), which makes the intent harder to follow in this already performance-sensitive cleanup loop. Rename them to interactionSessionIdsToKeep / interactionSessionIdsToRemove for clarity and consistency with the surrounding interactionSessionIds* naming.
                var interactionSessionIdChunk = interactionSessionIds.GetRange( chunkStartIndex, chunkSize );

                // Find a list of session IDs in the delete list that are being used for other interactions
                var interactionSessionsIdsToKeep = new InteractionService( rockContext )
                    .Queryable()

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Rock/Jobs/RockCleanup.cs
Comment on lines 1271 to +1275
var rockContext = new Rock.Data.RockContext();
rockContext.Database.CommandTimeout = commandTimeout;

// process 1K at a time to prevent the exception "Query processor ran out of internal resources".
for ( int x = 0; x < interactionSessionIds.Count / 1000; x++ )
{
var interactionSessionIdChunk = interactionSessionIds.Skip( x * 1000 ).Take( 1000 );
// Round the chunk count up. The previous integer division ( Count / 1000 ) dropped the
@stphnlee

Copy link
Copy Markdown
Author

Superseded by #14, which applies the same fix against hotfix-1.16.12 where the v16 upgrade work lives. Reopen if we want this on develop / upstream later.

@stphnlee stphnlee closed this Aug 31, 2026
@stphnlee
stphnlee deleted the fix-rockcleanup-interaction-retention branch August 31, 2026 13:27
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