Skip to content

fix: audience occurrence interval limits bypassed under memory pressure#455

Closed
yusuftor wants to merge 1 commit intodevelopfrom
fix/audience-occurrence-count-context-mismatch
Closed

fix: audience occurrence interval limits bypassed under memory pressure#455
yusuftor wants to merge 1 commit intodevelopfrom
fix/audience-occurrence-count-context-mismatch

Conversation

@yusuftor
Copy link
Collaborator

@yusuftor yusuftor commented Mar 18, 2026

Summary

  • Root cause: countAudienceOccurrences() and countPlacement() in CoreDataManager were delegating to CoreDataStack.count(), which runs queries on a separate NSManagedObjectContext from the one CoreDataManager uses for saving occurrences. Under memory pressure (e.g. low-RAM devices with frequent processTerminated events), the cross-context merge notification can be delayed, causing the count query to return 0 and bypassing the configured audience interval limit.
  • Fix: Both count methods now execute directly on CoreDataManager's own backgroundContext — the same context used by save(triggerAudienceOccurrence:) and savePlacementData() — ensuring immediate visibility of saved records.
  • Impact: Users on low-RAM devices could match an audience multiple times within its configured interval (e.g. 3 matches in 3 days despite a 1-month limit). This was confirmed in production on app 2201 (Gratitude Plus) for user KvFvRBoPVkMd6v93SqC2FpbbXzm1.

Changes

  • Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift: Rewrote countAudienceOccurrences() and countPlacement() to use self.backgroundContext directly instead of coreDataStack.count()
  • Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerTests.swift: Added 3 tests validating save-then-count visibility on the same context
  • CHANGELOG.md: Added 4.14.3 entry

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

Greptile Summary

This PR fixes a real production bug where audience occurrence interval limits were bypassed on low-RAM devices. The root cause was that countAudienceOccurrences() and countPlacement() were querying CoreDataStack's private backgroundContext while all saves went through CoreDataManager's separate backgroundContext. Under memory pressure, the cross-context merge notification was delayed, causing the count to return 0 and allowing repeated audience matches within the configured interval.

Key changes:

  • countAudienceOccurrences() and countPlacement() now perform count queries directly on CoreDataManager.backgroundContext — the same context used by all saves — ensuring immediate read-your-own-writes consistency.
  • Proper error handling with Logger.debug is added for count failures, returning 0 gracefully.
  • 3 new tests validate save-then-count visibility on the same context for audience occurrences.

Issues found:

  • getComputedPropertySincePlacement still delegates to coreDataStack.getLastSavedPlacement(), which uses CoreDataStack's own backgroundContext. This is the same class of cross-context staleness bug that this PR is fixing, and it was not addressed here.
  • The 3 new tests use XCTestCase APIs (XCTAssertEqual, expectation(description:), await fulfillment(of:)) instead of Swift's Testing framework, which CLAUDE.md mandates for all new tests.
  • No equivalent same-context tests were added for the countPlacement fix, and no test covers the fetchLimit cap behaviour in countAudienceOccurrences.

Confidence Score: 3/5

  • The core fix is correct and well-scoped, but getComputedPropertySincePlacement retains the same cross-context staleness issue, and new tests violate the project's testing framework conventions.
  • The fix correctly addresses the root cause for the two count methods by moving queries to the same context as saves. However, getComputedPropertySincePlacement is left using the old cross-context path (coreDataStack.getLastSavedPlacement), meaning the same class of bug persists for computed properties. The new tests also use XCTest instead of Swift Testing as required by CLAUDE.md, and test coverage for countPlacement's same-context fix is missing. These gaps reduce confidence in completeness.
  • Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swiftgetComputedPropertySincePlacement on line 168 still uses coreDataStack and needs the same treatment as the two fixed count methods.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift Rewrites countAudienceOccurrences and countPlacement to use CoreDataManager's own backgroundContext directly instead of coreDataStack.count(), fixing a cross-context staleness bug. getComputedPropertySincePlacement still delegates to coreDataStack and is susceptible to the same issue.
Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerTests.swift Adds 3 save-then-count tests for countAudienceOccurrences on the same context, but uses XCTest APIs contrary to CLAUDE.md's mandate to use Swift Testing for new tests. No equivalent same-context tests were added for countPlacement, and no test covers the fetchLimit cap behaviour.
CHANGELOG.md Adds a 4.14.3 entry documenting the audience occurrence interval fix. Entry is clear and customer-facing.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant CoreDataManager
    participant CDM_bgCtx as CoreDataManager.backgroundContext
    participant CDStack_bgCtx as CoreDataStack.backgroundContext (old path)

    Note over Caller,CDStack_bgCtx: BEFORE (cross-context, stale under memory pressure)
    Caller->>CoreDataManager: save(triggerAudienceOccurrence:)
    CoreDataManager->>CDM_bgCtx: perform { save() }
    Note right of CDM_bgCtx: Record saved here
    Caller->>CoreDataManager: countAudienceOccurrences(for:)
    CoreDataManager->>CDStack_bgCtx: coreDataStack.count(fetchRequest)
    Note right of CDStack_bgCtx: Merge notification may be delayed!
    CDStack_bgCtx-->>CoreDataManager: returns 0 (stale)
    CoreDataManager-->>Caller: 0 ❌ (limit bypassed)

    Note over Caller,CDStack_bgCtx: AFTER (same context, always consistent)
    Caller->>CoreDataManager: save(triggerAudienceOccurrence:)
    CoreDataManager->>CDM_bgCtx: perform { save() }
    Note right of CDM_bgCtx: Record saved here
    Caller->>CoreDataManager: countAudienceOccurrences(for:)
    CoreDataManager->>CDM_bgCtx: perform { count(for: fetchRequest) }
    CDM_bgCtx-->>CoreDataManager: returns 1 (fresh)
    CoreDataManager-->>Caller: 1 ✅ (limit enforced)
Loading

Comments Outside Diff (1)

  1. Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift, line 177-196 ([link](https://github.com/superwall/superwall-ios/blob/7f6bdf9e3418a4e054f372033813b6ff829331ca/Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift#L177-L196))

    P2 Same cross-context staleness issue not addressed

    getComputedPropertySincePlacement delegates to coreDataStack.getLastSavedPlacement(), which queries CoreDataStack's own backgroundContext — a completely separate NSManagedObjectContext from CoreDataManager.backgroundContext that is used for all saves. This is the exact same class of cross-context staleness bug that this PR fixes for countAudienceOccurrences and countPlacement. Under memory pressure with delayed merge notifications, a freshly saved placement may not be visible to this query, causing computed property calculations to return stale results.

    Consider moving this logic to use CoreDataManager's own backgroundContext directly, similar to the fix applied to the two count methods.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift
    Line: 177-196
    
    Comment:
    **Same cross-context staleness issue not addressed**
    
    `getComputedPropertySincePlacement` delegates to `coreDataStack.getLastSavedPlacement()`, which queries `CoreDataStack`'s own `backgroundContext` — a completely separate `NSManagedObjectContext` from `CoreDataManager.backgroundContext` that is used for all saves. This is the exact same class of cross-context staleness bug that this PR fixes for `countAudienceOccurrences` and `countPlacement`. Under memory pressure with delayed merge notifications, a freshly saved placement may not be visible to this query, causing computed property calculations to return stale results.
    
    Consider moving this logic to use `CoreDataManager`'s own `backgroundContext` directly, similar to the fix applied to the two count methods.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Storage/Core Data/CoreDataManager.swift
Line: 177-196

Comment:
**Same cross-context staleness issue not addressed**

`getComputedPropertySincePlacement` delegates to `coreDataStack.getLastSavedPlacement()`, which queries `CoreDataStack`'s own `backgroundContext` — a completely separate `NSManagedObjectContext` from `CoreDataManager.backgroundContext` that is used for all saves. This is the exact same class of cross-context staleness bug that this PR fixes for `countAudienceOccurrences` and `countPlacement`. Under memory pressure with delayed merge notifications, a freshly saved placement may not be visible to this query, causing computed property calculations to return stale results.

Consider moving this logic to use `CoreDataManager`'s own `backgroundContext` directly, similar to the fix applied to the two count methods.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerTests.swift
Line: 655-722

Comment:
**New tests use XCTest instead of Swift Testing framework**

`CLAUDE.md` explicitly states:

> "This project uses Swift's Testing framework (not XCTest) for all unit tests. Always use the Testing framework when writing new tests."

All three new tests (`test_saveAndCountAudienceOccurrence_sameContext`, `test_saveAndCountAudienceOccurrence_infinity`, `test_saveMultipleAndCountAudienceOccurrences`) use `XCTestCase` APIs (`expectation(description:)`, `await fulfillment(of:)`, `XCTAssertEqual`). They should be ported to Swift Testing (`@Test`, `#expect`, `withKnownIssue`, etc.) to stay consistent with the project's testing conventions.

Note: This applies even though the surrounding file already uses XCTest — per the guide, new tests should use Swift Testing.

**Rule Used:** CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=ae0afcf7-0b55-482b-9764-29f361e46714))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerTests.swift
Line: 699-722

Comment:
**`maxCount` capped by `fetchLimit` hides the real count**

In `countAudienceOccurrences`, `fetchRequest.fetchLimit` is set to `audienceOccurrence.maxCount` (which is `5` in this test), and `NSManagedObjectContext.count(for:)` respects `fetchLimit`. This means the test with `maxCount = 5` and 3 saved records will return `3` correctly, but if you had saved 6 records, the count would be capped at `5` (the fetchLimit), not `6`.

The test only validates the "below limit" case, so it doesn't exercise the cap behaviour. Consider adding a complementary case where `maxCount < numberOfSaves` to verify the limit is properly enforced (e.g., save 5 occurrences with `maxCount = 3` and assert `count == 3`).

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix: use same Core D..."

Greptile also left 2 inline comments on this PR.

Context used:

  • Rule used - CLAUDE.md (source)

The countAudienceOccurrences and countPlacement methods were delegating
to coreDataStack.count() which uses a separate backgroundContext from
the one CoreDataManager uses for saving. Under memory pressure, the
cross-context merge notification could be delayed, causing the count
query to return 0 and bypassing audience interval limits.

This fix moves both count methods to use CoreDataManager's own
backgroundContext directly, ensuring save and count operate on the
same context with immediate visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +655 to +722
func test_saveAndCountAudienceOccurrence_sameContext() async {
let key = "same_context_test"
let maxCount = 1
let interval: TriggerAudienceOccurrence.Interval = .minutes(60)
let occurrence = TriggerAudienceOccurrence(
key: key,
maxCount: maxCount,
interval: interval
)

// Save via CoreDataManager (uses its own backgroundContext)
let saveExpectation = expectation(description: "Saved occurrence")
coreDataManager.save(triggerAudienceOccurrence: occurrence) { _ in
saveExpectation.fulfill()
}
await fulfillment(of: [saveExpectation], timeout: 2.0)

// Count should find the saved occurrence on the same context
let count = await coreDataManager.countAudienceOccurrences(for: occurrence)
XCTAssertEqual(count, 1, "Count should see the occurrence saved on the same context")
}

func test_saveAndCountAudienceOccurrence_infinity() async {
let key = "infinity_context_test"
let maxCount = 1
let interval: TriggerAudienceOccurrence.Interval = .infinity
let occurrence = TriggerAudienceOccurrence(
key: key,
maxCount: maxCount,
interval: interval
)

// Save via CoreDataManager
let saveExpectation = expectation(description: "Saved occurrence")
coreDataManager.save(triggerAudienceOccurrence: occurrence) { _ in
saveExpectation.fulfill()
}
await fulfillment(of: [saveExpectation], timeout: 2.0)

// Count should find it immediately
let count = await coreDataManager.countAudienceOccurrences(for: occurrence)
XCTAssertEqual(count, 1, "Count should see the occurrence saved on the same context")
}

func test_saveMultipleAndCountAudienceOccurrences() async {
let key = "multi_save_test"
let maxCount = 5
let interval: TriggerAudienceOccurrence.Interval = .minutes(60)
let occurrence = TriggerAudienceOccurrence(
key: key,
maxCount: maxCount,
interval: interval
)

// Save 3 occurrences via CoreDataManager
let saveExpectation = expectation(description: "Saved occurrences")
saveExpectation.expectedFulfillmentCount = 3
for _ in 0..<3 {
coreDataManager.save(triggerAudienceOccurrence: occurrence) { _ in
saveExpectation.fulfill()
}
}
await fulfillment(of: [saveExpectation], timeout: 2.0)

// Count should find all 3
let count = await coreDataManager.countAudienceOccurrences(for: occurrence)
XCTAssertEqual(count, 3, "Count should see all occurrences saved on the same context")
}
Copy link

Choose a reason for hiding this comment

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

P2 New tests use XCTest instead of Swift Testing framework

CLAUDE.md explicitly states:

"This project uses Swift's Testing framework (not XCTest) for all unit tests. Always use the Testing framework when writing new tests."

All three new tests (test_saveAndCountAudienceOccurrence_sameContext, test_saveAndCountAudienceOccurrence_infinity, test_saveMultipleAndCountAudienceOccurrences) use XCTestCase APIs (expectation(description:), await fulfillment(of:), XCTAssertEqual). They should be ported to Swift Testing (@Test, #expect, withKnownIssue, etc.) to stay consistent with the project's testing conventions.

Note: This applies even though the surrounding file already uses XCTest — per the guide, new tests should use Swift Testing.

Rule Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerTests.swift
Line: 655-722

Comment:
**New tests use XCTest instead of Swift Testing framework**

`CLAUDE.md` explicitly states:

> "This project uses Swift's Testing framework (not XCTest) for all unit tests. Always use the Testing framework when writing new tests."

All three new tests (`test_saveAndCountAudienceOccurrence_sameContext`, `test_saveAndCountAudienceOccurrence_infinity`, `test_saveMultipleAndCountAudienceOccurrences`) use `XCTestCase` APIs (`expectation(description:)`, `await fulfillment(of:)`, `XCTAssertEqual`). They should be ported to Swift Testing (`@Test`, `#expect`, `withKnownIssue`, etc.) to stay consistent with the project's testing conventions.

Note: This applies even though the surrounding file already uses XCTest — per the guide, new tests should use Swift Testing.

**Rule Used:** CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=ae0afcf7-0b55-482b-9764-29f361e46714))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +699 to +722
func test_saveMultipleAndCountAudienceOccurrences() async {
let key = "multi_save_test"
let maxCount = 5
let interval: TriggerAudienceOccurrence.Interval = .minutes(60)
let occurrence = TriggerAudienceOccurrence(
key: key,
maxCount: maxCount,
interval: interval
)

// Save 3 occurrences via CoreDataManager
let saveExpectation = expectation(description: "Saved occurrences")
saveExpectation.expectedFulfillmentCount = 3
for _ in 0..<3 {
coreDataManager.save(triggerAudienceOccurrence: occurrence) { _ in
saveExpectation.fulfill()
}
}
await fulfillment(of: [saveExpectation], timeout: 2.0)

// Count should find all 3
let count = await coreDataManager.countAudienceOccurrences(for: occurrence)
XCTAssertEqual(count, 3, "Count should see all occurrences saved on the same context")
}
Copy link

Choose a reason for hiding this comment

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

P2 maxCount capped by fetchLimit hides the real count

In countAudienceOccurrences, fetchRequest.fetchLimit is set to audienceOccurrence.maxCount (which is 5 in this test), and NSManagedObjectContext.count(for:) respects fetchLimit. This means the test with maxCount = 5 and 3 saved records will return 3 correctly, but if you had saved 6 records, the count would be capped at 5 (the fetchLimit), not 6.

The test only validates the "below limit" case, so it doesn't exercise the cap behaviour. Consider adding a complementary case where maxCount < numberOfSaves to verify the limit is properly enforced (e.g., save 5 occurrences with maxCount = 3 and assert count == 3).

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Storage/Core Data/CoreDataManagerTests.swift
Line: 699-722

Comment:
**`maxCount` capped by `fetchLimit` hides the real count**

In `countAudienceOccurrences`, `fetchRequest.fetchLimit` is set to `audienceOccurrence.maxCount` (which is `5` in this test), and `NSManagedObjectContext.count(for:)` respects `fetchLimit`. This means the test with `maxCount = 5` and 3 saved records will return `3` correctly, but if you had saved 6 records, the count would be capped at `5` (the fetchLimit), not `6`.

The test only validates the "below limit" case, so it doesn't exercise the cap behaviour. Consider adding a complementary case where `maxCount < numberOfSaves` to verify the limit is properly enforced (e.g., save 5 occurrences with `maxCount = 3` and assert `count == 3`).

How can I resolve this? If you propose a fix, please make it concise.

@yusuftor
Copy link
Collaborator Author

Closing — the root cause is that presentationDidFinish() never fires on low-RAM devices because the WebView gets stuck in a terminate-reload loop, so the occurrence is never saved to Core Data. The 4.14.1 fix for terminated webviews refreshing in a loop addresses this. The same-context change in this PR was a minor improvement but doesn't fix the actual bug.

@yusuftor yusuftor closed this Mar 18, 2026
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.

1 participant