Przeglądaj źródła

Fix duplicate Nightscout treatment uploads by serializing per-pipeline runs

Concurrent upload runs of the same pipeline could both fetch the same rows
still marked isUploadedToNS == false and POST them twice. Nightscout's upsert
is a non-atomic find-then-insert, so near-simultaneous identical POSTs create
duplicate documents - most visibly Exercise entries for overrides enacted via
Shortcuts or remote commands, which fire both the notification path and the
Core Data path at once.

Replace the per-pipeline subject/throttle machinery with
NightscoutUploadSerializer, an actor that starts a run immediately when the
pipeline is idle and otherwise queues exactly one follow-up run; further
requests coalesce into it. No two runs of a pipeline can overlap, no request
is ever dropped (the old 2s throttle with latest: false silently discarded
triggers arriving inside its window), and there is no fixed delay anymore.
The awaitable upload*() entry points keep their semantics, so App Intents
still post their completion notification only after their upload finished.

uploadDeviceStatus() no longer throws; its only caller was the deleted
pipeline dispatch, and errors are logged in performUpload(for:).
Jonas Björkert 4 dni temu
rodzic
commit
977e9b6af5

+ 8 - 0
Trio.xcodeproj/project.pbxproj

@@ -540,6 +540,7 @@
 		BD8E6B212D9036CA00ABF8FA /* OnboardingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8E6B202D9036CA00ABF8FA /* OnboardingProvider.swift */; };
 		BD8E6B232D9036F700ABF8FA /* OnboardingDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8E6B222D9036F700ABF8FA /* OnboardingDataFlow.swift */; };
 		BD8FC0542D66186000B95AED /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0532D66186000B95AED /* TestError.swift */; };
+		FB9012AE66284A92A6531037 /* NightscoutUploadSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */; };
 		BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */; };
 		BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0582D66189700B95AED /* TestAssembly.swift */; };
 		BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */; };
@@ -783,6 +784,7 @@
 		DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */; };
+		B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */; };
 		DD906BF62EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */; };
 		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
 		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
@@ -1544,6 +1546,7 @@
 		BD8E6B202D9036CA00ABF8FA /* OnboardingProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingProvider.swift; sourceTree = "<group>"; };
 		BD8E6B222D9036F700ABF8FA /* OnboardingDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingDataFlow.swift; sourceTree = "<group>"; };
 		BD8FC0532D66186000B95AED /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = "<group>"; };
+		C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadSerializerTests.swift; sourceTree = "<group>"; };
 		BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0582D66189700B95AED /* TestAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAssembly.swift; sourceTree = "<group>"; };
 		BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationStorageTests.swift; sourceTree = "<group>"; };
@@ -1792,6 +1795,7 @@
 		DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTClaims.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadPipeline.swift; sourceTree = "<group>"; };
+		10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadSerializer.swift; sourceTree = "<group>"; };
 		DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseNightscoutManager+Subscribers.swift"; sourceTree = "<group>"; };
 		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
 		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
@@ -3009,6 +3013,7 @@
 				3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */,
 				B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
+				C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */,
 				BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */,
 				CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */,
 			);
@@ -4261,6 +4266,7 @@
 			isa = PBXGroup;
 			children = (
 				DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */,
+				10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */,
 				3811DE9725C9D88300A708ED /* NightscoutManager.swift */,
 				38FE826C25CC8461001FF17A /* NightscoutAPI.swift */,
 				DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */,
@@ -4924,6 +4930,7 @@
 				CE7CA3552A064973004BE681 /* ListStateIntent.swift in Sources */,
 				C28DD7262DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift in Sources */,
 				DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */,
+				B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */,
 				BDF530D82B40F8AC002CAF43 /* LockScreenView.swift in Sources */,
 				195D80B72AF697B800D25097 /* DynamicSettingsDataFlow.swift in Sources */,
 				DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */,
@@ -5642,6 +5649,7 @@
 				3BE2F1E82E030E2F009E2900 /* MealCobTests.swift in Sources */,
 				3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
+				FB9012AE66284A92A6531037 /* NightscoutUploadSerializerTests.swift in Sources */,
 				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */,

+ 2 - 2
Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift

@@ -31,8 +31,8 @@ extension BaseNightscoutManager {
             .store(in: &subscriptions)
     }
 
-    /// Maps Core Data entity changes into upload pipeline requests. We rely on
-    /// per-pipeline throttle so rapid changes don’t spam Nightscout.
+    /// Maps Core Data entity changes into upload pipeline requests. Requests are
+    /// coalesced and serialized per pipeline so rapid changes don’t spam Nightscout.
     func wireCoreDataSubscribers() {
         coreDataPublisher?
             .filteredByEntityName("OrefDetermination")

+ 52 - 45
Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift

@@ -13,7 +13,7 @@ protocol NightscoutManager: GlucoseSource {
     func deleteCarbs(withID id: String) async
     func deleteInsulin(withID id: String) async
     func deleteGlucose(withID id: String, withDate date: Date) async
-    func uploadDeviceStatus() async throws
+    func uploadDeviceStatus() async
     func uploadGlucose() async
     func uploadCarbs() async
     func uploadPumpHistory() async
@@ -44,59 +44,35 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
     private var ping: TimeInterval?
 
-    // Queue where upload pipelines run.
-    let uploadPipelineQueue = DispatchQueue(label: "NightscoutManager.uploadPipelines", qos: .utility)
+    /// Coalesces and serializes upload runs so no two runs of the same pipeline overlap.
+    /// Runs execute `performUpload(for:)`, provided at init. The public `upload*()`
+    /// methods and `requestUpload(_:)` go through the serializer; the `performUpload*()`
+    /// bodies must never call those entry points, or a same-pipeline run deadlocks
+    /// behind itself.
+    private var uploadSerializer: NightscoutUploadSerializer!
 
     // Background Core Data context for fetches used by upload tasks.
     var backgroundContext = CoreDataStack.shared.newTaskContext()
 
-    /// Throttle window (seconds) per upload pipeline. Any requests inside this window
-    /// coalesce into a single upload run for that pipeline.
-    let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
-        .carbs: 2, .pumpHistory: 2, .overrides: 2, .tempTargets: 2,
-        .glucose: 2, .deviceStatus: 2
-    ]
-
-    /// Subjects used to request an upload pipeline. The pipeline applies a throttle so
-    /// close calls don’t double-upload.
-    var uploadPipelineSubjects: [NightscoutUploadPipeline: PassthroughSubject<Void, Never>] = {
-        var d: [NightscoutUploadPipeline: PassthroughSubject<Void, Never>] = [:]
-        NightscoutUploadPipeline.allCases.forEach { d[$0] = PassthroughSubject<Void, Never>() }
-        return d
-    }()
-
     /// Request an upload for a pipeline (enqueue work). Safe to call from anywhere.
+    /// Bursts of requests coalesce: at most one run follows the one currently in flight.
     func requestUpload(_ uploadPipeline: NightscoutUploadPipeline) {
-        uploadPipelineSubjects[uploadPipeline]?.send(())
-    }
-
-    /// Build the Combine pipelines for all upload pipelines: subject → throttle → upload.
-    /// Must be called once during init().
-    func setupLanePipelines() {
-        for pipeline in NightscoutUploadPipeline.allCases {
-            guard let subject = uploadPipelineSubjects[pipeline], let window = uploadPipelineInterval[pipeline] else { continue }
-            subject
-                .receive(on: uploadPipelineQueue)
-                .throttle(for: .seconds(window), scheduler: uploadPipelineQueue, latest: false)
-                .sink { [weak self] in
-                    guard let self else { return }
-                    Task(priority: .utility) { await self.runUploadPipeline(pipeline) }
-                }
-                .store(in: &subscriptions)
+        Task(priority: .utility) { [weak self] in
+            await self?.uploadSerializer.request(uploadPipeline)
         }
     }
 
-    /// Runs the actual upload for a single upload pipeline.
-    /// Called by the throttled pipeline, not directly by callers.
-    func runUploadPipeline(_ uploadPipeline: NightscoutUploadPipeline) async {
+    /// Dispatches to a pipeline's upload routine. Runs inside the serializer; must only
+    /// call `performUpload*()` functions, never the public `upload*()` entry points.
+    private func performUpload(for uploadPipeline: NightscoutUploadPipeline) async {
         switch uploadPipeline {
-        case .carbs: await uploadCarbs()
-        case .pumpHistory: await uploadPumpHistory()
-        case .overrides: await uploadOverrides()
-        case .tempTargets: await uploadTempTargets()
-        case .glucose: await uploadGlucose()
+        case .carbs: await performUploadCarbs()
+        case .pumpHistory: await performUploadPumpHistory()
+        case .overrides: await performUploadOverrides()
+        case .tempTargets: await performUploadTempTargets()
+        case .glucose: await performUploadGlucose()
         case .deviceStatus:
-            do { try await uploadDeviceStatus() }
+            do { try await performUploadDeviceStatus() }
             catch { debug(.nightscout, "deviceStatus upload failed: \(error)") }
         }
     }
@@ -142,6 +118,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
 
     init(resolver: Resolver) {
         injectServices(resolver)
+
+        uploadSerializer = NightscoutUploadSerializer { [weak self] pipeline in
+            await self?.performUpload(for: pipeline)
+        }
+
         subscribe()
 
         coreDataPublisher =
@@ -152,7 +133,6 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
 
         setupNotification()
 
-        setupLanePipelines()
         wireSubscribers()
 
         /// Ensure that Nightscout Manager holds the `lastEnactedDetermination`, if one exists, on initialization.
@@ -182,6 +162,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
     }
 
+    /// Direct upload triggers for App Intents / remote control, which await the upload
+    /// before ending their background task. Uploads are serialized per pipeline, so these
+    /// may fire alongside the Core Data triggers for the same change without double-uploading.
     func setupNotification() {
         Foundation.NotificationCenter.default.publisher(for: .willUpdateOverrideConfiguration)
             .sink { [weak self] _ in
@@ -408,7 +391,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     ///
     /// - Note: Ensure `nightscoutAPI` is initialized and `isUploadEnabled` is set to `true` before invoking this function.
     /// - Returns: Nothing.
-    func uploadDeviceStatus() async throws {
+    func uploadDeviceStatus() async {
+        await uploadSerializer.run(.deviceStatus)
+    }
+
+    private func performUploadDeviceStatus() async throws {
         guard let nightscout = nightscoutAPI, isUploadEnabled else {
             debug(.nightscout, "NS API not available or upload disabled. Aborting NS Status upload.")
             return
@@ -764,6 +751,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     func uploadGlucose() async {
+        await uploadSerializer.run(.glucose)
+    }
+
+    private func performUploadGlucose() async {
         do {
             try await uploadGlucose(glucoseStorage.getGlucoseNotYetUploadedToNightscout())
             try await uploadNonCoreDataTreatments(glucoseStorage.getCGMStateNotYetUploadedToNightscout())
@@ -776,6 +767,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     func uploadPumpHistory() async {
+        await uploadSerializer.run(.pumpHistory)
+    }
+
+    private func performUploadPumpHistory() async {
         do {
             try await uploadPumpHistory(pumpHistoryStorage.getPumpHistoryNotYetUploadedToNightscout())
         } catch {
@@ -787,6 +782,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     func uploadCarbs() async {
+        await uploadSerializer.run(.carbs)
+    }
+
+    private func performUploadCarbs() async {
         do {
             try await uploadCarbs(carbsStorage.getCarbsNotYetUploadedToNightscout())
             try await uploadCarbs(carbsStorage.getFPUsNotYetUploadedToNightscout())
@@ -799,6 +798,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     func uploadOverrides() async {
+        await uploadSerializer.run(.overrides)
+    }
+
+    private func performUploadOverrides() async {
         do {
             try await uploadOverrides(overridesStorage.getOverridesNotYetUploadedToNightscout())
             try await uploadOverrideRuns(overridesStorage.getOverrideRunsNotYetUploadedToNightscout())
@@ -811,6 +814,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     func uploadTempTargets() async {
+        await uploadSerializer.run(.tempTargets)
+    }
+
+    private func performUploadTempTargets() async {
         do {
             try await uploadTempTargets(await tempTargetsStorage.getTempTargetsNotYetUploadedToNightscout())
             try await uploadTempTargetRuns(await tempTargetsStorage.getTempTargetRunsNotYetUploadedToNightscout())

+ 3 - 3
Trio/Sources/Services/Network/Nightscout/NightscoutUploadPipeline.swift

@@ -1,8 +1,8 @@
 import Foundation
 
 /// Logical upload “paths” handled by NightscoutManager.
-/// Each upload pipeline has its own throttled queue so we don’t double-upload
-/// when multiple sources trigger the same work close together.
+/// Runs are coalesced and serialized per pipeline (see `NightscoutUploadSerializer`)
+/// so we don’t double-upload when multiple sources trigger the same work close together.
 public enum NightscoutUploadPipeline: String, CaseIterable {
     case carbs
     case pumpHistory
@@ -28,7 +28,7 @@ public extension Foundation.Notification.Name {
 }
 
 /// Convenience helper any component (e.g. APSManager) can call to
-/// request uploads. The work is enqueued and deduped per upload pipeline via throttle,
+/// request uploads. The work is coalesced and serialized per upload pipeline,
 /// so rapid duplicate calls won’t double-upload.
 ///
 /// - Parameters:

+ 69 - 0
Trio/Sources/Services/Network/Nightscout/NightscoutUploadSerializer.swift

@@ -0,0 +1,69 @@
+import Foundation
+
+/// Coalesces and serializes Nightscout upload runs per pipeline.
+///
+/// Runs of the same pipeline never overlap: concurrent runs would each fetch the
+/// rows still marked `isUploadedToNS == false` and POST them twice, and Nightscout
+/// stores concurrent identical POSTs as duplicates. A follow-up run re-fetches only
+/// after the previous run has marked its rows uploaded, so nothing is sent twice.
+///
+/// An idle pipeline starts a run immediately; while a run is in flight, exactly one
+/// follow-up is queued and further requests coalesce into it. No request is dropped.
+/// Every run executes the upload operation provided at init.
+actor NightscoutUploadSerializer {
+    /// Uploads everything still pending for a pipeline.
+    private let uploadOperation: @Sendable(NightscoutUploadPipeline) async -> Void
+
+    /// Run currently in flight per pipeline.
+    private var current: [NightscoutUploadPipeline: Task<Void, Never>] = [:]
+    /// Follow-up run queued behind the current one, at most one per pipeline.
+    private var queued: [NightscoutUploadPipeline: Task<Void, Never>] = [:]
+
+    init(uploadOperation: @escaping @Sendable(NightscoutUploadPipeline) async -> Void) {
+        self.uploadOperation = uploadOperation
+    }
+
+    /// Fire-and-forget request. Coalesces into an already queued follow-up if present.
+    func request(_ pipeline: NightscoutUploadPipeline) {
+        _ = scheduleRun(pipeline)
+    }
+
+    /// Awaitable request: returns once the run serving it has completed. For callers
+    /// that must know the upload attempt has finished before proceeding.
+    func run(_ pipeline: NightscoutUploadPipeline) async {
+        await scheduleRun(pipeline).value
+    }
+
+    /// Returns the run that serves a request made now: the queued follow-up if one
+    /// exists, a new follow-up chained behind the current run, or a fresh run.
+    private func scheduleRun(_ pipeline: NightscoutUploadPipeline) -> Task<Void, Never> {
+        if let followUp = queued[pipeline] {
+            return followUp
+        }
+        if let running = current[pipeline] {
+            let followUp = Task { [running, uploadOperation, weak self] in
+                await running.value
+                await self?.promoteQueuedRun(pipeline)
+                await uploadOperation(pipeline)
+                await self?.finishCurrentRun(pipeline)
+            }
+            queued[pipeline] = followUp
+            return followUp
+        }
+        let run = Task { [uploadOperation, weak self] in
+            await uploadOperation(pipeline)
+            await self?.finishCurrentRun(pipeline)
+        }
+        current[pipeline] = run
+        return run
+    }
+
+    private func promoteQueuedRun(_ pipeline: NightscoutUploadPipeline) {
+        current[pipeline] = queued[pipeline]
+        queued[pipeline] = nil
+    }
+
+    private func finishCurrentRun(_ pipeline: NightscoutUploadPipeline) {
+        current[pipeline] = nil
+    }
+}

+ 173 - 0
TrioTests/NightscoutUploadSerializerTests.swift

@@ -0,0 +1,173 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Nightscout Upload Serializer Tests") struct NightscoutUploadSerializerTests {
+    /// Records whether an operation is currently in flight and flags any overlap.
+    private actor OverlapDetector {
+        private var inFlight = false
+        private(set) var overlapDetected = false
+        private(set) var completedRuns = 0
+
+        func enter() {
+            if inFlight { overlapDetected = true }
+            inFlight = true
+        }
+
+        func exit() {
+            inFlight = false
+            completedRuns += 1
+        }
+    }
+
+    /// Ordered log of named events across concurrent tasks, with a run counter.
+    private actor EventLog {
+        private(set) var events: [String] = []
+        private var runNumber = 0
+
+        func append(_ event: String) { events.append(event) }
+
+        func beginRun() -> Int {
+            runNumber += 1
+            return runNumber
+        }
+    }
+
+    /// One-shot gate that suspends waiters until opened.
+    private actor Gate {
+        private var isOpen = false
+        private var waiters: [CheckedContinuation<Void, Never>] = []
+
+        func open() {
+            isOpen = true
+            for waiter in waiters { waiter.resume() }
+            waiters.removeAll()
+        }
+
+        func wait() async {
+            if isOpen { return }
+            await withCheckedContinuation { waiters.append($0) }
+        }
+    }
+
+    /// Polls until `condition` is true, giving up after ~2 seconds so a broken
+    /// serializer fails the test instead of hanging it.
+    private func waitUntil(_ condition: @escaping @Sendable() async -> Bool) async throws {
+        for _ in 0 ..< 400 {
+            if await condition() { return }
+            try await Task.sleep(nanoseconds: 5_000_000)
+        }
+    }
+
+    @Test("Concurrent runs of the same pipeline never overlap") func noOverlapWithinPipeline() async {
+        let detector = OverlapDetector()
+        let serializer = NightscoutUploadSerializer { _ in
+            await detector.enter()
+            try? await Task.sleep(nanoseconds: 20_000_000)
+            await detector.exit()
+        }
+
+        await withTaskGroup(of: Void.self) { group in
+            for _ in 0 ..< 10 {
+                group.addTask { await serializer.run(.overrides) }
+            }
+        }
+
+        #expect(await detector.overlapDetected == false, "No two runs of the same pipeline may be in flight at once")
+        #expect(await detector.completedRuns >= 1, "At least one run must execute")
+    }
+
+    @Test("A run requested mid-flight executes only after the current one finishes") func queuedRunWaitsForCurrent() async throws {
+        let log = EventLog()
+        let gate = Gate()
+        let serializer = NightscoutUploadSerializer { _ in
+            let n = await log.beginRun()
+            await log.append("start-\(n)")
+            if n == 1 { await gate.wait() }
+            await log.append("end-\(n)")
+        }
+
+        await serializer.request(.overrides)
+        try await waitUntil { await log.events.contains("start-1") }
+
+        await serializer.request(.overrides)
+        await gate.open()
+        try await waitUntil { await log.events.contains("end-2") }
+
+        #expect(await log.events == ["start-1", "end-1", "start-2", "end-2"])
+    }
+
+    @Test("Requests made while a run is in flight coalesce into a single follow-up run") func requestsCoalesceIntoOneFollowUp(
+    ) async throws {
+        let log = EventLog()
+        let gate = Gate()
+        let serializer = NightscoutUploadSerializer { _ in
+            let n = await log.beginRun()
+            await log.append("run-\(n)")
+            if n == 1 { await gate.wait() }
+        }
+
+        await serializer.request(.glucose)
+        try await waitUntil { await log.events.contains("run-1") }
+
+        // All five requests arrive while the first run is blocked, so they must
+        // coalesce into exactly one follow-up run.
+        for _ in 0 ..< 5 {
+            await serializer.request(.glucose)
+        }
+
+        await gate.open()
+        try await waitUntil { await log.events.contains("run-2") }
+        // Grace period: any extra (incorrect) follow-up runs would surface here.
+        try await Task.sleep(nanoseconds: 100_000_000)
+
+        #expect(await log.events == ["run-1", "run-2"], "Coalesced requests must produce exactly one follow-up run")
+    }
+
+    @Test("run returns only after its own operation has completed") func runAwaitsOwnOperation() async {
+        let log = EventLog()
+        let serializer = NightscoutUploadSerializer { _ in
+            let n = await log.beginRun()
+            await log.append("run-\(n)")
+        }
+
+        await serializer.run(.carbs)
+        #expect(await log.events == ["run-1"])
+
+        await serializer.run(.carbs)
+        #expect(await log.events == ["run-1", "run-2"])
+    }
+
+    @Test("Different pipelines run independently of each other") func pipelinesAreIndependent() async throws {
+        let log = EventLog()
+        let gate = Gate()
+        let serializer = NightscoutUploadSerializer { pipeline in
+            if pipeline == .overrides {
+                await log.append("overrides-start")
+                await gate.wait()
+                await log.append("overrides-end")
+            } else {
+                await log.append("tempTargets-done")
+            }
+        }
+
+        await serializer.request(.overrides)
+        try await waitUntil { await log.events.contains("overrides-start") }
+
+        // Safety valve: if pipelines were wrongly serialized against each other,
+        // the tempTargets run below would be stuck behind the gated overrides run.
+        // Opening the gate after a delay lets the test fail on ordering instead of hanging.
+        let safetyValve = Task {
+            try? await Task.sleep(nanoseconds: 2_000_000_000)
+            await gate.open()
+        }
+
+        await serializer.run(.tempTargets)
+        await gate.open()
+        try await waitUntil { await log.events.contains("overrides-end") }
+        safetyValve.cancel()
+
+        #expect(await log.events == ["overrides-start", "tempTargets-done", "overrides-end"])
+    }
+}