Skip to content

Commit 0389487

Browse files
refactor(audience-session): apply two-phase lock to SetConsent and Reset's Identity.Reset
Extends acc3bab to the remaining _initLock callers that still held blocking work: SetConsent held _initLock through Session.Dispose (heartbeat drain, up to 1s on Full→None), ConsentStore.Save (disk), PurgeAll (disk I/O under _drainLock), Identity.Reset (File.Delete), and ApplyAnonymousDowngrade (synchronous file rewrite). Same two-phase shape as Shutdown: flip _consent and swap _session / _userId under the lock; persist, dispose, purge, downgrade, sync, and start outside. Reset still held Identity.Reset inside the lock. Hoisted for symmetry with Shutdown / SetConsent. Order in Phase 2 is Dispose → PurgeAll → Identity.Reset → newSession.Start, matching the in-lock sequence it replaces (session_end is enqueued then wiped; new session_start runs against the fresh anonymousId). Updated the class-level memory-model comment with one paragraph naming the four lifecycle methods and why their hold time is now nanoseconds, not seconds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c470586 commit 0389487

1 file changed

Lines changed: 73 additions & 44 deletions

File tree

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ public static class ImmutableAudience
1616
// `volatile _initialized` flag first so they never see a half-initialised state.
1717
// _consent and _session are written only inside _initLock but read outside,
1818
// so they stay `volatile` to make writes visible across threads.
19-
// _userId is written outside the lock (Identify, Reset) — `volatile` for the same reason.
19+
// _userId is written outside the lock (Identify) — `volatile` for the same reason.
20+
//
21+
// Init / Shutdown / Reset / SetConsent hold _initLock only to flip state
22+
// and capture references; they release the lock before running blocking
23+
// teardown (Session.Dispose, timer drain, queue shutdown, transport
24+
// flush, disposes). This keeps the hold time to nanoseconds so a caller
25+
// arriving on a different thread is not stranded behind those budgets.
2026
private static AudienceConfig? _config;
2127
private static DiskStore? _store;
2228
private static EventQueue? _queue;
@@ -258,23 +264,22 @@ public static void Alias(string fromId, string fromType, string toId, string toT
258264
// and then purged). Call FlushAsync() first to preserve queued events.
259265
public static void Reset()
260266
{
261-
// Phase 1 under _initLock: swap _session and reset identity. Blocking
262-
// work (session drain, disk purge, new session_start) runs outside
263-
// the lock so callers racing on _initLock don't wait on it.
267+
// Phase 1 under _initLock: swap _session and clear _userId. Blocking
268+
// work (session drain, disk purge, identity wipe, new session_start)
269+
// runs outside the lock so callers racing on _initLock don't wait.
270+
AudienceConfig? config;
264271
Session? oldSession;
265272
Session? newSession = null;
266273
EventQueue? queueForPurge;
267274

268275
lock (_initLock)
269276
{
270277
if (!_initialized) return;
271-
var config = _config;
278+
config = _config;
272279
if (config == null) return;
273280

274281
oldSession = _session;
275282
queueForPurge = _queue;
276-
277-
Identity.Reset(config.PersistentDataPath!);
278283
_userId = null;
279284

280285
// Swap under the lock so racing SetConsent/OnPause/OnResume see
@@ -283,11 +288,13 @@ public static void Reset()
283288
newSession = _session;
284289
}
285290

286-
// Phase 2 outside _initLock:
287-
// Dispose enqueues session_end → PurgeAll wipes it → Start emits the
288-
// new session_start. Order matches the in-lock sequence this replaces.
291+
// Phase 2 outside _initLock. Order: Dispose enqueues session_end →
292+
// PurgeAll wipes it → Identity.Reset clears the anonymousId file →
293+
// Start emits the new session_start against the fresh id. Matches
294+
// the in-lock sequence this replaces.
289295
oldSession?.Dispose();
290296
queueForPurge?.PurgeAll();
297+
Identity.Reset(config.PersistentDataPath!);
291298
newSession?.Start();
292299
}
293300

@@ -367,69 +374,91 @@ public static void SetConsent(ConsentLevel level)
367374
{
368375
if (!_initialized) return;
369376

370-
// Serialised under _initLock: prevents concurrent upgrades from each
371-
// building a fresh Session (stranding timers), and prevents racing
372-
// Init's _session assignment.
373-
Session? sessionToStart = null;
377+
// Phase 1 under _initLock: flip _consent and swap _session / _userId.
378+
// Phase 2 outside the lock runs the blocking side effects (persist,
379+
// dispose, purge, downgrade, backend sync, new session_start) so a
380+
// concurrent Shutdown / Init / Reset isn't held waiting on them.
381+
ConsentLevel previous;
382+
AudienceConfig? config;
383+
EventQueue? queue;
384+
Session? oldSession = null;
385+
Session? newSession = null;
386+
string? anonymousIdForPut;
387+
bool downgradeFullToAnonymous = false;
388+
374389
lock (_initLock)
375390
{
376391
if (!_initialized) return;
377392

378-
var config = _config;
379-
var queue = _queue;
393+
config = _config;
394+
queue = _queue;
380395
if (config == null) return;
381396

382-
var previous = _consent;
397+
previous = _consent;
383398
if (level == previous) return;
384399

385400
// Snapshot anonymousId before Identity.Reset (on None) wipes it.
386401
// The PUT audit trail needs to record whose consent changed.
387-
var anonymousIdForPut = previous == ConsentLevel.None
402+
anonymousIdForPut = previous == ConsentLevel.None
388403
? Identity.GetOrCreate(config.PersistentDataPath!, level)
389404
: Identity.Get(config.PersistentDataPath!);
390405

391406
_consent = level;
392407

393-
try
394-
{
395-
// PersistentDataPath validated non-null in Init; compiler can't propagate that.
396-
ConsentStore.Save(config.PersistentDataPath!, level);
397-
}
398-
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
399-
{
400-
Log.Warn($"SetConsent — failed to persist consent level: {ex.GetType().Name}: {ex.Message}. " +
401-
"In-memory level is updated but will revert on next launch.");
402-
NotifyErrorCallback(config.OnError, AudienceErrorCode.ConsentPersistFailed,
403-
$"Consent persist failed: {ex.Message}");
404-
}
405-
406408
if (level == ConsentLevel.None)
407409
{
408-
// Dispose for timer cleanup. session_end is gated out by
409-
// CanTrack (post-flip), matching revocation semantics.
410-
_session?.Dispose();
410+
// Swap the session reference under the lock; dispose outside.
411+
// session_end is gated out by CanTrack (post-flip), matching
412+
// revocation semantics.
413+
oldSession = _session;
411414
_session = null;
412-
413-
queue?.PurgeAll();
414-
Identity.Reset(config.PersistentDataPath!);
415415
}
416416
else if (previous == ConsentLevel.Full && level == ConsentLevel.Anonymous)
417417
{
418418
_userId = null;
419-
queue?.ApplyAnonymousDowngrade();
419+
downgradeFullToAnonymous = true;
420420
}
421421
else if (previous == ConsentLevel.None && _session == null)
422422
{
423-
// Upgrade from None: previous session was disposed. Start a
424-
// fresh one. Start() deferred to outside the lock.
425-
_session = new Session(Track);
426-
sessionToStart = _session;
423+
// Upgrade from None: allocate + publish the new Session under
424+
// the lock so a concurrent SetConsent / Init sees the new
425+
// reference and the double-allocation guard above fires.
426+
newSession = new Session(Track);
427+
_session = newSession;
427428
}
429+
}
428430

429-
SyncConsentToBackend(config, level, anonymousIdForPut);
431+
// Phase 2 outside _initLock.
432+
try
433+
{
434+
// PersistentDataPath validated non-null in Init; compiler can't propagate that.
435+
ConsentStore.Save(config.PersistentDataPath!, level);
436+
}
437+
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
438+
{
439+
Log.Warn($"SetConsent — failed to persist consent level: {ex.GetType().Name}: {ex.Message}. " +
440+
"In-memory level is updated but will revert on next launch.");
441+
NotifyErrorCallback(config.OnError, AudienceErrorCode.ConsentPersistFailed,
442+
$"Consent persist failed: {ex.Message}");
430443
}
431444

432-
sessionToStart?.Start();
445+
if (level == ConsentLevel.None)
446+
{
447+
oldSession?.Dispose();
448+
queue?.PurgeAll();
449+
Identity.Reset(config.PersistentDataPath!);
450+
}
451+
else if (downgradeFullToAnonymous)
452+
{
453+
// Synchronous: EventQueue.ApplyAnonymousDowngrade holds _drainLock
454+
// while it rewrites on-disk files, blocking the in-queue drain
455+
// and shutting the race with HttpTransport. See the method's comment.
456+
queue?.ApplyAnonymousDowngrade();
457+
}
458+
459+
newSession?.Start();
460+
461+
SyncConsentToBackend(config, level, anonymousIdForPut);
433462
}
434463

435464
// Fire-and-forget PUT /v1/audience/tracking-consent.

0 commit comments

Comments
 (0)