Skip to content

Commit e4fd044

Browse files
docs: Add docs about the ProviderOperationsQueue
Signed-off-by: Fabrizio Demaria <fabrizio.f.demaria@gmail.com>
1 parent 735ed28 commit e4fd044

File tree

1 file changed

+288
-0
lines changed

1 file changed

+288
-0
lines changed
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
# AsyncProviderOperationsQueue
2+
3+
## Overview
4+
5+
`AsyncProviderOperationsQueue` is a specialized serial async task queue that provides **operation-type-aware last-wins semantics** for handling OpenFeature provider operations. It ensures thread-safe, ordered execution of async operations while optimizing performance by coalescing redundant operations.
6+
7+
## Key Characteristics
8+
9+
- **Serial Execution**: Operations execute one at a time, preserving order
10+
- **Actor-based**: Thread-safe through Swift's actor isolation
11+
- **Smart Coalescing**: Automatically skips redundant operations based on last-wins semantics
12+
- **Continuation Management**: All callers receive completion notification, even if their operation was skipped
13+
14+
## Core Concepts
15+
16+
### Operation Types
17+
18+
The queue distinguishes between two types of operations:
19+
20+
1. **Non-Last-Wins (`lastWins: false`)**
21+
- Always executes
22+
- Processes in strict FIFO order
23+
- Used for critical state changes that must not be skipped
24+
- Examples: `setProvider()`, `clearProvider()`
25+
26+
2. **Last-Wins (`lastWins: true`)**
27+
- May be skipped if superseded by newer last-wins operations
28+
- Optimizes away intermediate states
29+
- Used for operations where only the final state matters
30+
- Examples: `setEvaluationContext()`
31+
32+
### Batching Logic
33+
34+
When processing the queue, operations are grouped into "batches":
35+
36+
- **Batch 1**: A single non-last-wins operation
37+
- **Batch 2**: Consecutive last-wins operations → only the last one executes
38+
39+
## How It Works
40+
41+
### Architecture
42+
43+
```
44+
┌─────────────────────────────────────────┐
45+
│ AsyncProviderOperationsQueue (Actor) │
46+
├─────────────────────────────────────────┤
47+
│ - queue: [QueuedOperation] │
48+
│ - currentTask: Task<Void, Never>? │
49+
├─────────────────────────────────────────┤
50+
│ + run(lastWins:operation:) async │
51+
│ - processNext() │
52+
└─────────────────────────────────────────┘
53+
54+
QueuedOperation {
55+
operation: () async -> Void
56+
continuation: CheckedContinuation<Void, Never>
57+
lastWins: Bool
58+
}
59+
```
60+
61+
### Execution Flow
62+
63+
```
64+
1. Caller invokes run(lastWins:operation:)
65+
66+
2. Operation wrapped with continuation and enqueued
67+
68+
3. If no task running → processNext()
69+
70+
4. Determine batch type
71+
├─ Non-last-wins: Execute single operation
72+
└─ Last-wins: Find consecutive last-wins ops
73+
→ Execute only the LAST one
74+
→ Skip all others
75+
76+
5. Resume ALL continuations (skipped + executed)
77+
78+
6. Recursively processNext() until queue empty
79+
```
80+
81+
### Example Scenarios
82+
83+
#### Scenario 1: Non-Last-Wins Operations
84+
85+
```swift
86+
// Queue: Empty, currentTask: nil
87+
88+
await queue.run(lastWins: false) { setProvider(A) } // Op1
89+
await queue.run(lastWins: false) { setProvider(B) } // Op2
90+
await queue.run(lastWins: false) { clearProvider() } // Op3
91+
92+
// Execution order:
93+
// 1. setProvider(A) ✓ Executed
94+
// 2. setProvider(B) ✓ Executed
95+
// 3. clearProvider() ✓ Executed
96+
// All three operations execute in order
97+
```
98+
99+
#### Scenario 2: Last-Wins Coalescing
100+
101+
```swift
102+
// Queue: Empty, currentTask: nil
103+
104+
await queue.run(lastWins: true) { setContext(ctx1) } // Op1
105+
await queue.run(lastWins: true) { setContext(ctx2) } // Op2
106+
await queue.run(lastWins: true) { setContext(ctx3) } // Op3
107+
108+
// Assume Op1 starts executing before Op2/Op3 are enqueued:
109+
// 1. setContext(ctx1) ✓ Executed (already running)
110+
// 2. setContext(ctx2) ✗ Skipped (superseded by ctx3)
111+
// 3. setContext(ctx3) ✓ Executed (last in batch)
112+
113+
// Result: Only ctx1 and ctx3 execute
114+
// Op2's continuation still resumes immediately when Op3 completes
115+
```
116+
117+
#### Scenario 3: Mixed Operations
118+
119+
```swift
120+
// Queue: Empty, currentTask: nil
121+
122+
await queue.run(lastWins: false) { setProvider(A) } // Op1
123+
await queue.run(lastWins: true) { setContext(ctx1) } // Op2
124+
await queue.run(lastWins: true) { setContext(ctx2) } // Op3
125+
await queue.run(lastWins: false) { setProvider(B) } // Op4
126+
await queue.run(lastWins: true) { setContext(ctx3) } // Op5
127+
128+
// Execution flow:
129+
// Batch 1: [Op1] non-last-wins
130+
// → setProvider(A) ✓ Executed
131+
132+
// Batch 2: [Op2, Op3] consecutive last-wins
133+
// → setContext(ctx1) ✗ Skipped
134+
// → setContext(ctx2) ✓ Executed (last in batch)
135+
136+
// Batch 3: [Op4] non-last-wins
137+
// → setProvider(B) ✓ Executed
138+
139+
// Batch 4: [Op5] last-wins
140+
// → setContext(ctx3) ✓ Executed
141+
142+
// Total executions: Op1, Op2(skipped), Op3, Op4, Op5
143+
```
144+
145+
## Implementation Details
146+
147+
### Actor Isolation
148+
149+
The queue is implemented as a Swift `actor`, providing:
150+
- Automatic serialization of all property access
151+
- Thread-safe state management
152+
- No manual locking required
153+
154+
### Continuation Management
155+
156+
```swift
157+
await withCheckedContinuation { continuation in
158+
queue.append(QueuedOperation(
159+
operation: operation,
160+
continuation: continuation,
161+
lastWins: lastWins
162+
))
163+
// ...
164+
}
165+
```
166+
167+
**Key Points:**
168+
- Each caller gets a continuation that suspends their async context
169+
- Continuations resume when the operation completes OR is skipped
170+
- This ensures all callers receive notification, preventing deadlocks
171+
172+
### Batch Processing Algorithm
173+
174+
```swift
175+
private func processNext() {
176+
guard !queue.isEmpty else { return }
177+
178+
let firstOp = queue[0]
179+
180+
if !firstOp.lastWins {
181+
// Execute single non-last-wins operation
182+
let op = queue.removeFirst()
183+
currentTask = Task {
184+
await op.operation()
185+
op.continuation.resume() // Resume caller
186+
await self?.processNext() // Process next batch
187+
}
188+
} else {
189+
// Find consecutive last-wins operations
190+
var lastWinsCount = 0
191+
for op in queue {
192+
if op.lastWins { lastWinsCount += 1 }
193+
else { break }
194+
}
195+
196+
// Execute only the LAST one
197+
let toSkip = queue.prefix(lastWinsCount - 1)
198+
let toExecute = queue[lastWinsCount - 1]
199+
queue.removeFirst(lastWinsCount)
200+
201+
currentTask = Task {
202+
await toExecute.operation() // Execute only last
203+
204+
// Resume ALL continuations
205+
for op in toSkip {
206+
op.continuation.resume() // Resume skipped callers
207+
}
208+
toExecute.continuation.resume() // Resume executed caller
209+
210+
await self?.processNext()
211+
}
212+
}
213+
}
214+
```
215+
216+
## Usage in OpenFeatureAPI
217+
218+
### Non-Last-Wins Operations
219+
220+
Used for operations that must always execute:
221+
222+
```swift
223+
// Setting a provider (critical state change)
224+
private func setProviderInternal(provider: FeatureProvider, ...) async {
225+
await unifiedQueue.run(lastWins: false) {
226+
// Update state and initialize provider
227+
// This MUST execute - cannot be skipped
228+
}
229+
}
230+
231+
// Clearing a provider (critical state change)
232+
private func clearProviderInternal() async {
233+
await unifiedQueue.run(lastWins: false) {
234+
// Clear provider state
235+
// This MUST execute - cannot be skipped
236+
}
237+
}
238+
```
239+
240+
### Last-Wins Operations
241+
242+
Used for operations where only the final state matters:
243+
244+
```swift
245+
// Updating evaluation context
246+
private func updateContext(evaluationContext: EvaluationContext) async {
247+
await unifiedQueue.run(lastWins: true) {
248+
// Update context and call provider's onContextSet
249+
// If multiple context updates are queued, only the last one matters
250+
// Intermediate contexts can be safely skipped
251+
}
252+
}
253+
```
254+
255+
**Why Last-Wins for Context Updates?**
256+
257+
When a user rapidly changes the evaluation context (e.g., user switches profiles multiple times), we only care about the final context. Executing intermediate `onContextSet` calls is wasteful:
258+
259+
```swift
260+
// User rapidly switches profiles
261+
setEvaluationContext(userProfile1) // Queued
262+
setEvaluationContext(userProfile2) // Queued
263+
setEvaluationContext(userProfile3) // Queued
264+
265+
// Without coalescing: 3 expensive provider.onContextSet() calls
266+
// With coalescing: 1 call with userProfile3 (optimal!)
267+
```
268+
269+
## Future Enhancements
270+
271+
Potential improvements:
272+
273+
1. **Priority Queuing**: Allow high-priority operations to jump ahead
274+
2. **Operation Cancellation**: Cancel pending operations explicitly
275+
3. **Metrics/Telemetry**: Track coalesced operations for monitoring
276+
4. **Configurable Coalescing**: Allow per-operation coalescing strategy
277+
278+
## Related Files
279+
280+
- `Sources/OpenFeature/OpenFeatureAPI.swift` - Primary consumer
281+
- `Tests/OpenFeatureTests/ProviderOperationsQueueTests.swift` - Comprehensive tests
282+
- `Sources/OpenFeature/Provider/FeatureProvider.swift` - Provider interface
283+
284+
## References
285+
286+
- [Swift Actors](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID645)
287+
- [Checked Continuations](https://developer.apple.com/documentation/swift/checkedcontinuation)
288+
- [OpenFeature Specification](https://openfeature.dev/specification/)

0 commit comments

Comments
 (0)