Skip to content

Commit a1e8e7c

Browse files
committed
init async action
1 parent f090092 commit a1e8e7c

File tree

10 files changed

+764
-5
lines changed

10 files changed

+764
-5
lines changed

ASYNC_ACTIONS.md

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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

Comments
 (0)