Skip to content

Commit 1ec0378

Browse files
committed
Implement polling for topics
1 parent 691cd73 commit 1ec0378

15 files changed

Lines changed: 1056 additions & 29 deletions

README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,83 @@ Both publishers and consumers accept a queue name and configuration as parameter
244244

245245
If you do not want to create a new queue/topic, you can set `queueLocator` field for `queueConfiguration`. In that case `message-queue-toolkit` will not attempt to create a new queue or topic, and instead throw an error if they don't already exist.
246246

247+
## Resource Availability Polling (Eventual Consistency Mode)
248+
249+
When using `locatorConfig` to reference existing queues or topics, the default behavior is to fail immediately if the resource doesn't exist. However, in some deployment scenarios (especially with cross-service dependencies), resources may not be available at startup time.
250+
251+
The `resourceAvailabilityConfig` option enables "eventual consistency mode" where consumers poll for resources to become available instead of failing immediately.
252+
253+
### Use Case: Cross-Service Dependencies
254+
255+
Consider two services that need to subscribe to each other's topics:
256+
- **Service A** needs to subscribe to **Service B's** topic
257+
- **Service B** needs to subscribe to **Service A's** topic
258+
259+
Without eventual consistency mode, neither service can deploy first because the other's topic doesn't exist yet. With `resourceAvailabilityConfig`, both services can start simultaneously and wait for the other's resources to appear.
260+
261+
### Configuration
262+
263+
```typescript
264+
const consumer = new MySnsSqsConsumer(dependencies, {
265+
locatorConfig: {
266+
topicArn: 'arn:aws:sns:...',
267+
queueUrl: 'https://sqs...',
268+
subscriptionArn: '...'
269+
},
270+
// Enable eventual consistency mode
271+
resourceAvailabilityConfig: {
272+
enabled: true, // Enable polling for resource availability
273+
pollingIntervalMs: 5000, // Check every 5 seconds (default: 5000)
274+
timeoutMs: 300000, // Fail after 5 minutes (optional, default: no timeout)
275+
}
276+
})
277+
```
278+
279+
### Configuration Options
280+
281+
| Option | Type | Default | Description |
282+
|--------|------|---------|-------------|
283+
| `enabled` | `boolean` | `false` | Enable eventual consistency mode |
284+
| `pollingIntervalMs` | `number` | `5000` | Interval between availability checks (ms) |
285+
| `timeoutMs` | `number` | `undefined` | Maximum wait time before throwing `ResourceAvailabilityTimeoutError`. If not set, polls indefinitely |
286+
287+
### Environment-Specific Configuration
288+
289+
```typescript
290+
// Development/Staging - poll indefinitely
291+
{
292+
resourceAvailabilityConfig: {
293+
enabled: true,
294+
pollingIntervalMs: 5000,
295+
}
296+
}
297+
298+
// Production - poll with timeout to catch misconfigurations
299+
{
300+
resourceAvailabilityConfig: {
301+
enabled: true,
302+
pollingIntervalMs: 10000,
303+
timeoutMs: 5 * 60 * 1000, // 5 minutes
304+
}
305+
}
306+
```
307+
308+
### Error Handling
309+
310+
When `timeoutMs` is configured and the resource doesn't become available within the specified time, a `ResourceAvailabilityTimeoutError` is thrown:
311+
312+
```typescript
313+
import { ResourceAvailabilityTimeoutError } from '@message-queue-toolkit/core'
314+
315+
try {
316+
await consumer.init()
317+
} catch (error) {
318+
if (error instanceof ResourceAvailabilityTimeoutError) {
319+
console.error(`Resource ${error.resourceName} not available after ${error.timeoutMs}ms`)
320+
}
321+
}
322+
```
323+
247324
## SQS Policy Configuration
248325

249326
SQS queues can be configured with access policies to control who can send messages to and receive messages from the queue. The `policyConfig` parameter allows you to define these policies when creating or updating SQS queues.

packages/core/lib/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,12 @@ export { isProduction, reloadConfig } from './utils/envUtils.ts'
105105
export { isShallowSubset, objectMatches } from './utils/matchUtils.ts'
106106
export { type ParseMessageResult, parseMessage } from './utils/parseUtils.ts'
107107
export { objectToBuffer } from './utils/queueUtils.ts'
108+
export {
109+
isResourceAvailabilityWaitingEnabled,
110+
type ResourceAvailabilityCheckResult,
111+
ResourceAvailabilityTimeoutError,
112+
type WaitForResourceOptions,
113+
waitForResource,
114+
} from './utils/resourceAvailabilityUtils.ts'
108115
export { toDatePreprocessor } from './utils/toDateProcessor.ts'
109116
export { waitAndRetry } from './utils/waitUtils.ts'

packages/core/lib/queues/AbstractQueueService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import type {
4646
ProcessedMessageMetadata,
4747
QueueDependencies,
4848
QueueOptions,
49+
ResourceAvailabilityConfig,
4950
} from '../types/queueOptionsTypes.ts'
5051
import { isRetryDateExceeded } from '../utils/dateUtils.ts'
5152
import { streamWithKnownSizeToString } from '../utils/streamUtils.ts'
@@ -125,6 +126,7 @@ export abstract class AbstractQueueService<
125126
protected readonly creationConfig?: QueueConfiguration
126127
protected readonly locatorConfig?: QueueLocatorType
127128
protected readonly deletionConfig?: DeletionConfig
129+
protected readonly resourceAvailabilityConfig?: ResourceAvailabilityConfig
128130
protected readonly payloadStoreConfig?:
129131
| MakeRequired<SinglePayloadStoreConfig, 'serializer'>
130132
| MakeRequired<MultiPayloadStoreConfig, 'serializer'>
@@ -160,6 +162,7 @@ export abstract class AbstractQueueService<
160162
this.creationConfig = options.creationConfig
161163
this.locatorConfig = options.locatorConfig
162164
this.deletionConfig = options.deletionConfig
165+
this.resourceAvailabilityConfig = options.resourceAvailabilityConfig
163166
this.payloadStoreConfig = options.payloadStoreConfig
164167
? {
165168
serializer: jsonStreamStringifySerializer,

packages/core/lib/types/queueOptionsTypes.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ export type CommonQueueOptions = {
118118
deletionConfig?: DeletionConfig
119119
payloadStoreConfig?: PayloadStoreConfig
120120
messageDeduplicationConfig?: MessageDeduplicationConfig
121+
/**
122+
* Configuration for eventual consistency mode.
123+
* When enabled, the consumer will poll for the topic/queue to become available
124+
* instead of failing immediately when using locatorConfig.
125+
* This is useful for handling cross-service dependencies during deployment.
126+
*/
127+
resourceAvailabilityConfig?: ResourceAvailabilityConfig
121128
}
122129

123130
export type CommonCreationConfigType = {
@@ -130,6 +137,59 @@ export type DeletionConfig = {
130137
forceDeleteInProduction?: boolean
131138
}
132139

140+
/**
141+
* Configuration for eventual consistency mode when resources may not exist at startup.
142+
*
143+
* This is useful in scenarios where services have cross-dependencies:
144+
* - Service A needs to subscribe to Service B's topic
145+
* - Service B needs to subscribe to Service A's topic
146+
* - Neither can deploy first without the other's topic existing
147+
*
148+
* With `resourceAvailabilityConfig.enabled = true`, the consumer will poll for the
149+
* topic/queue to become available instead of failing immediately.
150+
*
151+
* @example
152+
* // Development/staging - poll indefinitely
153+
* {
154+
* resourceAvailabilityConfig: {
155+
* enabled: true,
156+
* pollingIntervalMs: 5000,
157+
* }
158+
* }
159+
*
160+
* @example
161+
* // Production - poll with timeout to catch misconfigurations
162+
* {
163+
* resourceAvailabilityConfig: {
164+
* enabled: true,
165+
* timeoutMs: 5 * 60 * 1000, // 5 minutes
166+
* pollingIntervalMs: 10000,
167+
* }
168+
* }
169+
*/
170+
export type ResourceAvailabilityConfig = {
171+
/**
172+
* If true, the consumer will poll for the topic/queue to become available
173+
* instead of failing immediately when using locatorConfig.
174+
* Default: false (fail immediately for backwards compatibility)
175+
*/
176+
enabled: boolean
177+
178+
/**
179+
* Maximum time in milliseconds to wait for the resource to become available.
180+
* If not set or set to 0, will poll indefinitely (useful for dev/staging environments).
181+
* For production, it's recommended to set a reasonable timeout (e.g., 5 minutes).
182+
* Default: undefined (no timeout - poll indefinitely)
183+
*/
184+
timeoutMs?: number
185+
186+
/**
187+
* Interval in milliseconds between polling attempts.
188+
* Default: 5000 (5 seconds)
189+
*/
190+
pollingIntervalMs?: number
191+
}
192+
133193
type NewQueueOptions<CreationConfigType extends CommonCreationConfigType> = {
134194
creationConfig?: CreationConfigType
135195
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import {
3+
isResourceAvailabilityWaitingEnabled,
4+
ResourceAvailabilityTimeoutError,
5+
waitForResource,
6+
} from './resourceAvailabilityUtils.ts'
7+
8+
describe('resourceAvailabilityUtils', () => {
9+
describe('isResourceAvailabilityWaitingEnabled', () => {
10+
it('returns true when enabled is true', () => {
11+
expect(isResourceAvailabilityWaitingEnabled({ enabled: true })).toBe(true)
12+
})
13+
14+
it('returns false when enabled is false', () => {
15+
expect(isResourceAvailabilityWaitingEnabled({ enabled: false })).toBe(false)
16+
})
17+
18+
it('returns false when config is undefined', () => {
19+
expect(isResourceAvailabilityWaitingEnabled(undefined)).toBe(false)
20+
})
21+
})
22+
23+
describe('waitForResource', () => {
24+
it('returns immediately when resource is available on first check', async () => {
25+
const checkFn = vi.fn().mockResolvedValue({ isAvailable: true, result: 'test-result' })
26+
27+
const result = await waitForResource({
28+
config: { enabled: true, pollingIntervalMs: 100 },
29+
checkFn,
30+
resourceName: 'test-resource',
31+
})
32+
33+
expect(result).toBe('test-result')
34+
expect(checkFn).toHaveBeenCalledTimes(1)
35+
})
36+
37+
it('polls until resource becomes available', async () => {
38+
let callCount = 0
39+
const checkFn = vi.fn().mockImplementation(() => {
40+
callCount++
41+
if (callCount < 3) {
42+
return Promise.resolve({ isAvailable: false })
43+
}
44+
return Promise.resolve({ isAvailable: true, result: 'test-result' })
45+
})
46+
47+
const result = await waitForResource({
48+
config: { enabled: true, pollingIntervalMs: 10 },
49+
checkFn,
50+
resourceName: 'test-resource',
51+
})
52+
53+
expect(result).toBe('test-result')
54+
expect(checkFn).toHaveBeenCalledTimes(3)
55+
})
56+
57+
it('throws ResourceAvailabilityTimeoutError when timeout is reached', async () => {
58+
const checkFn = vi.fn().mockResolvedValue({ isAvailable: false })
59+
60+
await expect(
61+
waitForResource({
62+
config: { enabled: true, pollingIntervalMs: 10, timeoutMs: 50 },
63+
checkFn,
64+
resourceName: 'test-resource',
65+
}),
66+
).rejects.toThrow(ResourceAvailabilityTimeoutError)
67+
68+
// Should have made at least a few attempts
69+
expect(checkFn.mock.calls.length).toBeGreaterThan(0)
70+
})
71+
72+
it('throws the original error when checkFn throws', async () => {
73+
const checkFn = vi.fn().mockRejectedValue(new Error('Unexpected error'))
74+
75+
await expect(
76+
waitForResource({
77+
config: { enabled: true, pollingIntervalMs: 10 },
78+
checkFn,
79+
resourceName: 'test-resource',
80+
}),
81+
).rejects.toThrow('Unexpected error')
82+
83+
expect(checkFn).toHaveBeenCalledTimes(1)
84+
})
85+
86+
it('uses default polling interval when not specified', async () => {
87+
const checkFn = vi.fn().mockResolvedValue({ isAvailable: true, result: 'test-result' })
88+
89+
const result = await waitForResource({
90+
config: { enabled: true },
91+
checkFn,
92+
resourceName: 'test-resource',
93+
})
94+
95+
expect(result).toBe('test-result')
96+
})
97+
98+
it('logs progress when logger is provided', async () => {
99+
const logger = {
100+
info: vi.fn(),
101+
debug: vi.fn(),
102+
error: vi.fn(),
103+
}
104+
const checkFn = vi.fn().mockResolvedValue({ isAvailable: true, result: 'test-result' })
105+
106+
await waitForResource({
107+
config: { enabled: true, pollingIntervalMs: 10 },
108+
checkFn,
109+
resourceName: 'test-resource',
110+
// @ts-expect-error - partial logger for testing
111+
logger,
112+
})
113+
114+
expect(logger.info).toHaveBeenCalledWith(
115+
expect.objectContaining({
116+
message: expect.stringContaining('Waiting for resource'),
117+
resourceName: 'test-resource',
118+
}),
119+
)
120+
expect(logger.info).toHaveBeenCalledWith(
121+
expect.objectContaining({
122+
message: expect.stringContaining('is now available'),
123+
resourceName: 'test-resource',
124+
}),
125+
)
126+
})
127+
128+
it('polls indefinitely when timeoutMs is not set', async () => {
129+
let callCount = 0
130+
const checkFn = vi.fn().mockImplementation(() => {
131+
callCount++
132+
if (callCount < 10) {
133+
return Promise.resolve({ isAvailable: false })
134+
}
135+
return Promise.resolve({ isAvailable: true, result: 'test-result' })
136+
})
137+
138+
const result = await waitForResource({
139+
config: { enabled: true, pollingIntervalMs: 1 },
140+
checkFn,
141+
resourceName: 'test-resource',
142+
})
143+
144+
expect(result).toBe('test-result')
145+
expect(checkFn).toHaveBeenCalledTimes(10)
146+
})
147+
148+
it('polls indefinitely when timeoutMs is 0', async () => {
149+
let callCount = 0
150+
const checkFn = vi.fn().mockImplementation(() => {
151+
callCount++
152+
if (callCount < 5) {
153+
return Promise.resolve({ isAvailable: false })
154+
}
155+
return Promise.resolve({ isAvailable: true, result: 'test-result' })
156+
})
157+
158+
const result = await waitForResource({
159+
config: { enabled: true, pollingIntervalMs: 1, timeoutMs: 0 },
160+
checkFn,
161+
resourceName: 'test-resource',
162+
})
163+
164+
expect(result).toBe('test-result')
165+
expect(checkFn).toHaveBeenCalledTimes(5)
166+
})
167+
})
168+
169+
describe('ResourceAvailabilityTimeoutError', () => {
170+
it('includes resource name and timeout in message', () => {
171+
const error = new ResourceAvailabilityTimeoutError('my-queue', 5000)
172+
173+
expect(error.message).toContain('my-queue')
174+
expect(error.message).toContain('5000')
175+
expect(error.resourceName).toBe('my-queue')
176+
expect(error.timeoutMs).toBe(5000)
177+
expect(error.name).toBe('ResourceAvailabilityTimeoutError')
178+
})
179+
})
180+
})

0 commit comments

Comments
 (0)