diff --git a/packages/serverless-offline-sqs/src/sqs.js b/packages/serverless-offline-sqs/src/sqs.js index f1a6ba2..5084cc1 100644 --- a/packages/serverless-offline-sqs/src/sqs.js +++ b/packages/serverless-offline-sqs/src/sqs.js @@ -59,6 +59,21 @@ const delay = timeout => const NON_EXISTENT_QUEUE_ERRORS = ['AWS.SimpleQueueService.NonExistentQueue', 'QueueDoesNotExist']; const isNonExistentQueueError = err => includes(get('name', err), NON_EXISTENT_QUEUE_ERRORS); +// #189 (nicolaspfernandes): an event whose ARN resolves to a `.fifo`-suffixed name (e.g. +// `arn:...:QueueName.fifo`) yields GetQueueUrl({QueueName: 'QueueName.fifo'}), but ElasticMQ's +// configured queue id is literally `QueueName` — it does NOT append `.fifo` — so GetQueueUrl is +// rejected with QueueDoesNotExist and the listener never starts. There is no single correct name +// because the emulator's naming contract is ambiguous, so probe BOTH forms: the name exactly as +// given (it always wins when it exists) and the toggled-suffix variant (drop `.fifo` if present, +// else append it). Pure + total: returns an ordered, de-duplicated string[]; empty/nil -> []. +const FIFO_SUFFIX = '.fifo'; +const toggleFifoSuffix = queueName => + endsWith(FIFO_SUFFIX, queueName) + ? queueName.slice(0, -FIFO_SUFFIX.length) + : `${queueName}${FIFO_SUFFIX}`; +const queueNameCandidates = queueName => + isEmpty(queueName) ? [] : uniq(compact([queueName, toggleFifoSuffix(queueName)])); + // #253 (flipscholtz): MessageId is not guaranteed unique within a batch and can exceed the 80-char // `Id` limit. Derive the batch-entry Id from the array index so it is unique and short. const toDeleteEntries = messages => @@ -399,10 +414,19 @@ class SQS { return rewritedQueueUrl.href; } - async _getQueueUrl(queueName) { + // #189 (nicolaspfernandes): resolve the queue URL by probing each name candidate in order — the + // name exactly as given first, then the toggled `.fifo`-suffix variant — so a `.fifo` event name + // still resolves against a bare ElasticMQ queue id (and vice-versa). A QueueDoesNotExist on one + // candidate falls through to the next; only when EVERY candidate is genuinely missing do we keep + // the historical wait-for-availability behavior (delay, then retry the whole candidate set) so a + // not-yet-created queue is still awaited. A non-existence error is never swallowed — it just + // advances the probe. + async _getQueueUrl(queueName, candidates = queueNameCandidates(queueName)) { + const [candidate, ...rest] = candidates; try { - return await this.client.send(new GetQueueUrlCommand({QueueName: queueName})); + return await this.client.send(new GetQueueUrlCommand({QueueName: candidate})); } catch (err) { + if (rest.length > 0) return this._getQueueUrl(queueName, rest); await delay(10000); return this._getQueueUrl(queueName); } @@ -415,9 +439,9 @@ class SQS { if (this.options.autoCreate) await this._createQueue(sqsEvent); - const QueueUrl = this._rewriteQueueUrl( - (await this.client.send(new GetQueueUrlCommand({QueueName: queueName}))).QueueUrl - ); + // #189: resolve the URL through the candidate-probing helper so a `.fifo` event name still maps + // to a bare ElasticMQ queue id (and vice-versa) instead of rejecting forever. + const QueueUrl = this._rewriteQueueUrl((await this._getQueueUrl(queueName)).QueueUrl); // #227 (tomusiaka) + #123: use the event-level maximumBatchingWindow as the long-poll wait when // present; otherwise the configurable default (custom.serverless-offline-sqs.waitTimeSeconds / @@ -523,3 +547,4 @@ module.exports.extractDlqTargetName = extractDlqTargetName; module.exports.orderQueuesForCreation = orderQueuesForCreation; module.exports.isNonExistentQueueError = isNonExistentQueueError; module.exports.enqueueLoop = enqueueLoop; +module.exports.queueNameCandidates = queueNameCandidates; diff --git a/packages/serverless-offline-sqs/test/index.js b/packages/serverless-offline-sqs/test/index.js index 9fda7dd..476f793 100644 --- a/packages/serverless-offline-sqs/test/index.js +++ b/packages/serverless-offline-sqs/test/index.js @@ -17,7 +17,8 @@ const { normalizeQueueNames, expandSqsEventDefinitions, isNonExistentQueueError, - enqueueLoop + enqueueLoop, + queueNameCandidates } = require('../src/sqs'); const SQSEvent = require('../src/sqs-event'); const SQSEventDefinition = require('../src/sqs-event-definition'); @@ -1321,3 +1322,77 @@ test('#226 enqueueLoop leaves a resolving task untouched (no warning)', async t await enqueueLoop(queue, () => {}, normalizeLog({warning: msg => warnings.push(msg)})); t.deepEqual(warnings, []); }); + +// --------------------------------------------------------------------------- +// queueNameCandidates / GetQueueUrl .fifo-suffix fallback (#189 nicolaspfernandes) +// --------------------------------------------------------------------------- + +test('#189 queueNameCandidates drops the .fifo suffix as the second candidate', t => { + t.deepEqual(queueNameCandidates('QueueName.fifo'), ['QueueName.fifo', 'QueueName']); +}); + +test('#189 queueNameCandidates adds a .fifo suffix as the second candidate', t => { + t.deepEqual(queueNameCandidates('QueueName'), ['QueueName', 'QueueName.fifo']); +}); + +test('#189 queueNameCandidates keeps the original name first (it always wins when present)', t => { + t.is(queueNameCandidates('QueueName.fifo')[0], 'QueueName.fifo'); + t.is(queueNameCandidates('Orders')[0], 'Orders'); +}); + +test('#189 queueNameCandidates de-duplicates and tolerates empty/nil input', t => { + t.deepEqual(queueNameCandidates(''), []); + t.deepEqual(queueNameCandidates(undefined), []); + t.deepEqual(queueNameCandidates(null), []); + // a bare '.fifo' toggles to '' which is dropped -> single candidate + t.deepEqual(queueNameCandidates('.fifo'), ['.fifo']); +}); + +// A minimal ElasticMQ-like client mock: it only knows the queue names in `known`, and rejects any +// other QueueName with the v3 `QueueDoesNotExist` error — exactly the missing-param/no-such-queue +// failure #189 hits when the event ARN carries a `.fifo` suffix the emulator's id does not. +const mkSqsWithKnownQueues = known => { + const sqs = new SQS(null, {}, {region: 'eu-west-1', accountId: '0'}, undefined); + const seen = []; + sqs.client = { + send: command => { + const commandName = command.constructor.name; + if (commandName === 'GetQueueUrlCommand') { + const {QueueName} = command.input; + seen.push(QueueName); + if (known.includes(QueueName)) + return Promise.resolve({QueueUrl: `http://local/${QueueName}`}); + const err = new Error(`The specified queue does not exist: ${QueueName}`); + err.name = 'QueueDoesNotExist'; + return Promise.reject(err); + } + return Promise.resolve({}); + } + }; + return {sqs, seen}; +}; + +test('#189 _getQueueUrl resolves a .fifo event name against a bare ElasticMQ queue id', async t => { + // The event ARN resolves to `QueueName.fifo`, but ElasticMQ's configured id is literally + // `QueueName` (it does NOT append `.fifo`). Without the fallback, GetQueueUrl({QueueName: + // 'QueueName.fifo'}) is rejected forever; with it, the bare-name candidate resolves the URL. + const {sqs, seen} = mkSqsWithKnownQueues(['QueueName']); + const {QueueUrl} = await sqs._getQueueUrl('QueueName.fifo'); + t.is(QueueUrl, 'http://local/QueueName'); + t.deepEqual(seen, ['QueueName.fifo', 'QueueName']); +}); + +test('#189 _getQueueUrl resolves a bare event name against a .fifo ElasticMQ queue id', async t => { + // The mirror case: the event omits `.fifo` but the emulator id carries it. + const {sqs, seen} = mkSqsWithKnownQueues(['QueueName.fifo']); + const {QueueUrl} = await sqs._getQueueUrl('QueueName'); + t.is(QueueUrl, 'http://local/QueueName.fifo'); + t.deepEqual(seen, ['QueueName', 'QueueName.fifo']); +}); + +test('#189 _getQueueUrl returns the URL on the first candidate without trying the variant', async t => { + const {sqs, seen} = mkSqsWithKnownQueues(['QueueName.fifo']); + const {QueueUrl} = await sqs._getQueueUrl('QueueName.fifo'); + t.is(QueueUrl, 'http://local/QueueName.fifo'); + t.deepEqual(seen, ['QueueName.fifo']); +});