Skip to content

feat: [SDK-5065] share crash-record retention policy across platforms - #20

Open
abdulraqeeb33 wants to merge 2 commits into
mainfrom
ar/sdk-5065-shared-crash-retention
Open

feat: [SDK-5065] share crash-record retention policy across platforms#20
abdulraqeeb33 wants to merge 2 commits into
mainfrom
ar/sdk-5065-shared-crash-retention

Conversation

@abdulraqeeb33

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Moves the crash-record retention policy (age ceiling, count/byte caps, write-size limit) into commonMain so iOS gets it too, and corrects the ILogFileStore contract that currently mandates the unbounded behavior.

Relates to SDK-5065.

Why

Removing OpenTelemetry took disk-buffering's retention with it. Android rebuilt that policy over several review rounds; iOS implements ILogFileStore independently in Swift and has none of it:

  • save() enforces no size limit
  • listReadable() has only the lower minAgeMillis gate, no ceiling
  • deleteUnrecognizedEntries() reaps only .otlp.tmp, never owned records at any age
  • no count cap, no byte cap, no eviction

So an iOS crash record that fails to upload is re-read and re-POSTed every launch, indefinitely. The directory sits under .cachesDirectory, so the OS reclaims the disk under pressure — but the repeated re-upload is unmitigated.

Retention is pure decision logic over a directory listing. It was only in the Android module by accident of where it was written.

What

CrashRetention in commonMain owns the policy: ownership by suffix, expiry, overflow selection, a cheap within-caps predicate for crash paths, and the inventory formatter. Platforms supply the listing and a clock reading, then apply the returned decisions with their own I/O.

Design notes:

  • An object, not top-level functions, so Swift sees CrashRetention.shared.selectOverflowOwned(...) rather than a CrashRetentionKt file facade.
  • Every bound is a defaulted parameter. The constants are shared defaults, not hardcoded policy — a platform whose payload profile or cache-eviction pressure differs can pass its own without forking the algorithm.
  • isWithinCaps uses the same capped-claim arithmetic as selectOverflowOwned, so a crash-path fast exit can never disagree with the selector about whether work is needed. Android shipped a bug from exactly that disagreement.

Contract correction

ILogFileStore told implementers that owned records "must always be preserved". Taken literally — as iOS did — that is what leaves the directory unbounded. The KDoc now states the retention obligations and points at the shared helpers, and deleteUnrecognizedEntries documents that it is the only scan that runs when remote logging is off, so it is the sole chance to bound a directory nothing else reads.

Behavior change

None for Android — this is a lift of logic it already runs, with tests that already pass. iOS behavior does not change in this PR either; adopting CrashRetention in FileLogStore.swift is a follow-up in the iOS repo.

Testing

20 tests in commonTest covering ownership, the exact-ceiling and future-timestamp boundaries, oldest-first eviction, the skip-not-cutoff rule, keepName including the oversized case, name-based tie-breaking, cheap-path agreement with the selector, and inventory formatting. Fixed timestamps, no filesystem.

Green on both iosSimulatorArm64Test and testDebugUnitTest — the same 20 assertions on both platforms, which is the point.

:kmp:allTests and spotlessCheck pass.

Made with Cursor

Retention was Android-only: an age ceiling, count and byte caps, and a
write-time size limit that together keep the crash directory bounded. iOS
implements ILogFileStore independently and has none of them, so a record that
never uploads is re-read and re-POSTed on every launch and the directory grows
until the OS reclaims the cache.

The policy is pure decision logic over a directory listing, so it belongs here
rather than in either platform. CrashRetention takes entries plus a clock
reading and returns what to reclaim; platforms keep only the file I/O. Exposed
as an object so Swift sees CrashRetention.shared rather than a file facade, and
every bound is a defaulted parameter so a platform whose payload profile
differs can tune it without forking the algorithm.

Also corrects the ILogFileStore contract, which told implementers that owned
records "must always be preserved". Following that literally is what leaves the
directory unbounded. It now states the retention obligations and points at the
shared helpers.

20 tests run in commonTest against fixed timestamps and no filesystem, green on
both the iOS simulator and Android JVM.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 requested a review from a team as a code owner August 25, 2026 19:49
@abdulraqeeb33
abdulraqeeb33 requested a review from a team August 25, 2026 19:49
Kotlin `const val` is folded at compile time and never reaches the generated
Objective-C header, so Swift callers could see CrashRetention.shared but none
of the bounds it is parameterized by — the API was unusable from iOS, which is
the platform it exists for. Verified against the built XCFramework header:
only `shared` was exported.

Plain `val` does cross the boundary. Renamed to camelCase at the same time
since this is now a Swift-facing surface and the values are no longer
compile-time constants.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george

Copy link
Copy Markdown
Contributor

The shared policy looks right under the production defaults, and the tests lock the overflow cases that matter. A few things to fix before merge, since this is the Swift-facing surface the iOS follow-up will copy from:

  1. KDoc still links CrashRetention.MAX_RECORD_BYTES. That name is gone after the export rename. Point it at maxRecordBytes.
  2. Kotlin default args do not reach Swift, so every call has to pass the full parameter list including ownedSuffix. Easy silent drift. Add no-arg / keepName-only overloads, or a small policy object Swift can pass as CrashRetention.shared.defaultPolicy.
  3. Drop the lengthBytes = 0L default. A Kotlin caller that omits size makes every budget claim 0 and the 2MB cap never fires.
  4. A future lastModifiedMs never expires, never passes the min-age read gate, and overflow sorts it as newest, so it occupies a keep slot while real records get evicted. Clamp mtime to nowMs for overflow ordering.
  5. isWithinCaps_agrees_with_the_selector never asserts the byte branch false. Deleting claimed <= maxTotalBytes still passes the suite.

The rest (no callers yet, deleteUnrecognizedEntries still no-op, isWithinCaps vs keepName under custom bounds) can ride in the iOS adoption PR. Do not close SDK-5065 on this diff.

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