Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions packages/serverless-offline-sqs/src/sqs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -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);
}
Expand All @@ -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 /
Expand Down Expand Up @@ -523,3 +547,4 @@ module.exports.extractDlqTargetName = extractDlqTargetName;
module.exports.orderQueuesForCreation = orderQueuesForCreation;
module.exports.isNonExistentQueueError = isNonExistentQueueError;
module.exports.enqueueLoop = enqueueLoop;
module.exports.queueNameCandidates = queueNameCandidates;
77 changes: 76 additions & 1 deletion packages/serverless-offline-sqs/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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']);
});