|
| 1 | +# Async Actions for Destination Actions |
| 2 | + |
| 3 | +This document describes the implementation of asynchronous action support for Segment's Action Destinations framework. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +Previously, all actions in destination frameworks were synchronous - they would immediately return a response from the destination API. However, some destination APIs work asynchronously, accepting a request and then processing it in the background. For these cases, we need a way to: |
| 8 | + |
| 9 | +1. Submit the initial request and receive an operation ID |
| 10 | +2. Poll the status of the operation periodically |
| 11 | +3. Get the final result when the operation completes |
| 12 | + |
| 13 | +## Implementation |
| 14 | + |
| 15 | +### Core Types |
| 16 | + |
| 17 | +The async action support introduces several new types: |
| 18 | + |
| 19 | +```typescript |
| 20 | +// Response type for async operations |
| 21 | +export type AsyncActionResponseType = { |
| 22 | + /** Indicates this is an async operation */ |
| 23 | + isAsync: true |
| 24 | + /** Context data to be used for polling operations */ |
| 25 | + asyncContext: JSONLikeObject |
| 26 | + /** Optional message about the async operation */ |
| 27 | + message?: string |
| 28 | + /** Initial status code */ |
| 29 | + status?: number |
| 30 | +} |
| 31 | + |
| 32 | +// Response type for polling operations |
| 33 | +export type AsyncPollResponseType = { |
| 34 | + /** The current status of the async operation */ |
| 35 | + status: 'pending' | 'completed' | 'failed' |
| 36 | + /** Progress indicator (0-100) */ |
| 37 | + progress?: number |
| 38 | + /** Message about current state */ |
| 39 | + message?: string |
| 40 | + /** Final result data when status is 'completed' */ |
| 41 | + result?: JSONLikeObject |
| 42 | + /** Error information when status is 'failed' */ |
| 43 | + error?: { |
| 44 | + code: string |
| 45 | + message: string |
| 46 | + } |
| 47 | + /** Whether polling should continue */ |
| 48 | + shouldContinuePolling: boolean |
| 49 | +} |
| 50 | +``` |
| 51 | +
|
| 52 | +### Action Interface Changes |
| 53 | +
|
| 54 | +The `ActionDefinition` interface now supports an optional `poll` method: |
| 55 | +
|
| 56 | +```typescript |
| 57 | +interface ActionDefinition<Settings, Payload, AudienceSettings> { |
| 58 | + // ... existing fields ... |
| 59 | + |
| 60 | + /** The operation to poll the status of an async operation */ |
| 61 | + poll?: RequestFn<Settings, Payload, AsyncPollResponseType, AudienceSettings> |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +### Execution Context |
| 66 | + |
| 67 | +The `ExecuteInput` type now includes async context for poll operations: |
| 68 | + |
| 69 | +```typescript |
| 70 | +interface ExecuteInput<Settings, Payload, AudienceSettings> { |
| 71 | + // ... existing fields ... |
| 72 | + |
| 73 | + /** Async context data for polling operations */ |
| 74 | + readonly asyncContext?: JSONLikeObject |
| 75 | +} |
| 76 | +``` |
| 77 | + |
| 78 | +## Usage |
| 79 | + |
| 80 | +### 1. Implementing an Async Action |
| 81 | + |
| 82 | +Here's an example of how to implement an action that supports async operations: |
| 83 | + |
| 84 | +```typescript |
| 85 | +const action: ActionDefinition<Settings, Payload> = { |
| 86 | + title: 'Async Operation', |
| 87 | + description: 'An action that performs async operations', |
| 88 | + fields: { |
| 89 | + // ... field definitions ... |
| 90 | + }, |
| 91 | + |
| 92 | + perform: async (request, { settings, payload }) => { |
| 93 | + // Submit the operation to the destination |
| 94 | + const response = await request(`${settings.endpoint}/operations`, { |
| 95 | + method: 'post', |
| 96 | + json: payload |
| 97 | + }) |
| 98 | + |
| 99 | + // Check if this is an async operation |
| 100 | + if (response.data?.status === 'accepted' && response.data?.operation_id) { |
| 101 | + // Return async response with context for polling |
| 102 | + return { |
| 103 | + isAsync: true, |
| 104 | + asyncContext: { |
| 105 | + operation_id: response.data.operation_id, |
| 106 | + user_id: payload.user_id |
| 107 | + // Include any data needed for polling |
| 108 | + }, |
| 109 | + message: `Operation ${response.data.operation_id} submitted successfully`, |
| 110 | + status: 202 |
| 111 | + } as AsyncActionResponseType |
| 112 | + } |
| 113 | + |
| 114 | + // Return regular response for synchronous operations |
| 115 | + return response |
| 116 | + }, |
| 117 | + |
| 118 | + poll: async (request, { settings, asyncContext }) => { |
| 119 | + if (!asyncContext?.operation_id) { |
| 120 | + return { |
| 121 | + status: 'failed', |
| 122 | + error: { |
| 123 | + code: 'MISSING_CONTEXT', |
| 124 | + message: 'Operation ID not found in async context' |
| 125 | + }, |
| 126 | + shouldContinuePolling: false |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + // Poll the operation status |
| 131 | + const response = await request(`${settings.endpoint}/operations/${asyncContext.operation_id}`) |
| 132 | + const operationStatus = response.data?.status |
| 133 | + |
| 134 | + switch (operationStatus) { |
| 135 | + case 'pending': |
| 136 | + case 'processing': |
| 137 | + return { |
| 138 | + status: 'pending', |
| 139 | + progress: response.data?.progress || 0, |
| 140 | + message: `Operation is ${operationStatus}`, |
| 141 | + shouldContinuePolling: true |
| 142 | + } |
| 143 | + |
| 144 | + case 'completed': |
| 145 | + return { |
| 146 | + status: 'completed', |
| 147 | + progress: 100, |
| 148 | + message: 'Operation completed successfully', |
| 149 | + result: response.data?.result || {}, |
| 150 | + shouldContinuePolling: false |
| 151 | + } |
| 152 | + |
| 153 | + case 'failed': |
| 154 | + return { |
| 155 | + status: 'failed', |
| 156 | + error: { |
| 157 | + code: response.data?.error_code || 'OPERATION_FAILED', |
| 158 | + message: response.data?.error_message || 'Operation failed' |
| 159 | + }, |
| 160 | + shouldContinuePolling: false |
| 161 | + } |
| 162 | + } |
| 163 | + } |
| 164 | +} |
| 165 | +``` |
| 166 | + |
| 167 | +### 2. Checking for Async Support |
| 168 | + |
| 169 | +You can check if an action supports async operations: |
| 170 | + |
| 171 | +```typescript |
| 172 | +const action = new Action(destinationName, definition) |
| 173 | +if (action.hasPollSupport) { |
| 174 | + console.log('This action supports async operations') |
| 175 | +} |
| 176 | +``` |
| 177 | + |
| 178 | +### 3. Executing Async Operations |
| 179 | + |
| 180 | +**Initial Submission:** |
| 181 | + |
| 182 | +```typescript |
| 183 | +const result = await destination.executeAction('myAction', { |
| 184 | + event, |
| 185 | + mapping, |
| 186 | + settings |
| 187 | +}) |
| 188 | + |
| 189 | +// Check if it's an async operation |
| 190 | +if (result.isAsync) { |
| 191 | + const { operation_id } = result.asyncContext |
| 192 | + // Store operation_id for later polling |
| 193 | +} |
| 194 | +``` |
| 195 | + |
| 196 | +**Polling for Status:** |
| 197 | + |
| 198 | +```typescript |
| 199 | +const pollResult = await destination.executePoll('myAction', { |
| 200 | + event, |
| 201 | + mapping, |
| 202 | + settings, |
| 203 | + asyncContext: { operation_id: 'op_12345' } |
| 204 | +}) |
| 205 | + |
| 206 | +if (pollResult.status === 'completed') { |
| 207 | + console.log('Operation completed:', pollResult.result) |
| 208 | +} else if (pollResult.status === 'failed') { |
| 209 | + console.error('Operation failed:', pollResult.error) |
| 210 | +} else if (pollResult.shouldContinuePolling) { |
| 211 | + // Schedule another poll |
| 212 | + setTimeout(() => poll(), 5000) |
| 213 | +} |
| 214 | +``` |
| 215 | + |
| 216 | +## Framework Integration |
| 217 | + |
| 218 | +### Action Class Changes |
| 219 | + |
| 220 | +The `Action` class now includes: |
| 221 | + |
| 222 | +- `hasPollSupport: boolean` - indicates if the action supports polling |
| 223 | +- `executePoll()` method - executes the poll operation |
| 224 | + |
| 225 | +### Destination Class Changes |
| 226 | + |
| 227 | +The `Destination` class now includes: |
| 228 | + |
| 229 | +- `executePoll()` method - executes polling for a specific action |
| 230 | + |
| 231 | +## Error Handling |
| 232 | + |
| 233 | +Async actions should handle several error scenarios: |
| 234 | + |
| 235 | +1. **Missing Async Context:** When poll is called without required context data |
| 236 | +2. **Invalid Operation ID:** When the operation ID is not found |
| 237 | +3. **Network Errors:** When polling requests fail |
| 238 | +4. **Timeout:** When operations take too long |
| 239 | + |
| 240 | +## Best Practices |
| 241 | + |
| 242 | +1. **Always validate async context** in the poll method |
| 243 | +2. **Include meaningful progress indicators** when possible |
| 244 | +3. **Set appropriate polling intervals** to avoid overwhelming the destination API |
| 245 | +4. **Handle all possible operation states** (pending, completed, failed, unknown) |
| 246 | +5. **Provide clear error messages** for debugging |
| 247 | +6. **Store minimal context** needed for polling to reduce memory usage |
| 248 | + |
| 249 | +## Testing |
| 250 | + |
| 251 | +Testing async actions requires special consideration: |
| 252 | + |
| 253 | +```typescript |
| 254 | +describe('Async Action', () => { |
| 255 | + it('should handle async operations', async () => { |
| 256 | + // Test that async operations return proper async response |
| 257 | + const responses = await testDestination.testAction('asyncOperation', { |
| 258 | + event, |
| 259 | + mapping, |
| 260 | + settings |
| 261 | + }) |
| 262 | + |
| 263 | + // Verify the destination API was called correctly |
| 264 | + expect(responses[0].data.status).toBe('accepted') |
| 265 | + expect(responses[0].data.operation_id).toBeDefined() |
| 266 | + }) |
| 267 | + |
| 268 | + // Note: Direct poll testing requires calling the poll method directly |
| 269 | + // as the test framework doesn't support async response handling yet |
| 270 | +}) |
| 271 | +``` |
| 272 | + |
| 273 | +## Example Implementation |
| 274 | + |
| 275 | +See `/packages/destination-actions/src/destinations/example-async/` for a complete working example of an async action implementation. |
| 276 | + |
| 277 | +## Future Enhancements |
| 278 | + |
| 279 | +1. **Automatic Polling:** Framework could handle polling automatically |
| 280 | +2. **Exponential Backoff:** Built-in retry logic with backoff |
| 281 | +3. **Timeout Management:** Automatic timeout handling |
| 282 | +4. **Batch Polling:** Support for polling multiple operations at once |
| 283 | +5. **Test Framework Integration:** Better support for testing async responses |
| 284 | + |
| 285 | +## Migration Guide |
| 286 | + |
| 287 | +To add async support to an existing destination: |
| 288 | + |
| 289 | +1. Add the `poll` method to your action definition |
| 290 | +2. Modify the `perform` method to return `AsyncActionResponseType` when appropriate |
| 291 | +3. Update your destination settings if needed for polling endpoints |
| 292 | +4. Add tests for both sync and async code paths |
| 293 | +5. Update documentation to explain async behavior to users |
0 commit comments