Implement a PubSub debounce mechanism - node.js

I have different publishers publish to a PubSub Topic. Each message has a specific key. I would like to create subscribers that only pick up the latest message for each specific key within a defined interval. In other words, I would like to have some kind of debounce implemented for my subscribers.
Example (with debounce 2 seconds)
-(x)-(y)-(x)-------(z)-(z)---(x)-----------------> [Topic with messages]
|-------|---------------|execute for x [Subscriber]
2 seconds
|---------------|execute for y [Subscriber]
2 seconds
|---|---------------|execute for z [Subscriber]
2 seconds
|---------------|execute for x [Subscriber]
2 seconds
Ordered Execution Summary:
execute for message with key: y
execute for message with key: x
execute for message with key: z
execute for message with key: x
Implementation
// index.ts
import * as pubsub from '#google-cloud/pubsub';
import * as functions from 'firebase-functions';
import AbortController from 'node-abort-controller';
exports.Debouncer = functions
.runWith({
// runtimeOptions
})
.region('REGION')
.pubsub.topic('TOPIC_NAME')
.onPublish(async (message, context) => {
const key = message.json.key;
// when an equivalent topic is being received, cancel this calculation:
const aborter = await abortHelper<any>(
'TOPIC_NAME',
(message) => message?.key === key
).catch((error) => {
console.error('Failed to init abort helper', error);
throw new Error('Failed to init abort helper');
});
await new Promise((resolve) => setTimeout(resolve, 2000));
// here, run the EXECUTION for the key, unless an abortsignal from the abortHelper was received:
// if(aborter.abortController.signal) ...
aborter.teardown();
/**
* Subscribe to the first subscription found for the specified topic. Once a
* message gets received that is matching `messageMatcher`, the returned
* AbortController reflects the abortet state. Calling the returned teardown
* will cancel the subscription.
*/
async function abortHelper<TMessage>(
topicName: string,
messageMatcher: (message: TMessage) => boolean = () => true
) {
const abortController = new AbortController();
const pubSubClient = new pubsub.PubSub();
const topic = pubSubClient.topic(topicName);
const subscription = await topic
.getSubscriptions()
.then((subscriptionsResponse) => {
// TODO use better approach to find or provide subscription
const subscription = subscriptionsResponse?.[0]?.[0];
if (!subscription) {
throw new Error('no found subscription');
}
return subscription;
});
const listener = (message: TMessage) => {
const matching = messageMatcher(message);
if (matching) {
abortController.abort();
unsubscribeFromPubSubTopicSubscription();
}
};
subscription.addListener('message', listener);
return {
teardown: () => {
unsubscribeFromPubSubTopicSubscription();
},
abortController,
};
function unsubscribeFromPubSubTopicSubscription() {
subscription.removeListener('message', listener);
}
}
});
The initial idea was to register a cloud function to the topic. This cloud function itself then subscribes to the topic as well and waits for the defined interval. If it picks up a message with the same key during the interval, it exits the cloud function. Otherwise, it runs the execution.
Running inside the firebase-emulator this worked fine. However, on production random and hard to debug issues occurred most likely due to parallel execution of the functions.
What would be the best approach to implement such a system in a scalable way? (It does not necessarily have to be with PubSub.)

Related

Firebase Nodejs : DEADLINE_EXCEEDED: Deadline exceeded on set() [duplicate]

I took one of the sample functions from the Firestore documentation and was able to successfully run it from my local firebase environment. However, once I deployed to my firebase server, the function completes, but no entries are made in the firestore database. The firebase function logs show "Deadline Exceeded." I'm a bit baffled. Anyone know why this is happening and how to resolve this?
Here is the sample function:
exports.testingFunction = functions.https.onRequest((request, response) => {
var data = {
name: 'Los Angeles',
state: 'CA',
country: 'USA'
};
// Add a new document in collection "cities" with ID 'DC'
var db = admin.firestore();
var setDoc = db.collection('cities').doc('LA').set(data);
response.status(200).send();
});
Firestore has limits.
Probably “Deadline Exceeded” happens because of its limits.
See this. https://firebase.google.com/docs/firestore/quotas
Maximum write rate to a document 1 per second
https://groups.google.com/forum/#!msg/google-cloud-firestore-discuss/tGaZpTWQ7tQ/NdaDGRAzBgAJ
In my own experience, this problem can also happen when you try to write documents using a bad internet connection.
I use a solution similar to Jurgen's suggestion to insert documents in batch smaller than 500 at once, and this error appears if I'm using a not so stable wifi connection. When I plug in the cable, the same script with the same data runs without errors.
I have written this little script which uses batch writes (max 500) and only write one batch after the other.
use it by first creating a batchWorker let batch: any = new FbBatchWorker(db);
Then add anything to the worker batch.set(ref.doc(docId), MyObject);. And finish it via batch.commit().
The api is the same as for the normal Firestore Batch (https://firebase.google.com/docs/firestore/manage-data/transactions#batched-writes) However, currently it only supports set.
import { firestore } from "firebase-admin";
class FBWorker {
callback: Function;
constructor(callback: Function) {
this.callback = callback;
}
work(data: {
type: "SET" | "DELETE";
ref: FirebaseFirestore.DocumentReference;
data?: any;
options?: FirebaseFirestore.SetOptions;
}) {
if (data.type === "SET") {
// tslint:disable-next-line: no-floating-promises
data.ref.set(data.data, data.options).then(() => {
this.callback();
});
} else if (data.type === "DELETE") {
// tslint:disable-next-line: no-floating-promises
data.ref.delete().then(() => {
this.callback();
});
} else {
this.callback();
}
}
}
export class FbBatchWorker {
db: firestore.Firestore;
batchList2: {
type: "SET" | "DELETE";
ref: FirebaseFirestore.DocumentReference;
data?: any;
options?: FirebaseFirestore.SetOptions;
}[] = [];
elemCount: number = 0;
private _maxBatchSize: number = 490;
public get maxBatchSize(): number {
return this._maxBatchSize;
}
public set maxBatchSize(size: number) {
if (size < 1) {
throw new Error("Size must be positive");
}
if (size > 490) {
throw new Error("Size must not be larger then 490");
}
this._maxBatchSize = size;
}
constructor(db: firestore.Firestore) {
this.db = db;
}
async commit(): Promise<any> {
const workerProms: Promise<any>[] = [];
const maxWorker = this.batchList2.length > this.maxBatchSize ? this.maxBatchSize : this.batchList2.length;
for (let w = 0; w < maxWorker; w++) {
workerProms.push(
new Promise((resolve) => {
const A = new FBWorker(() => {
if (this.batchList2.length > 0) {
A.work(this.batchList2.pop());
} else {
resolve();
}
});
// tslint:disable-next-line: no-floating-promises
A.work(this.batchList2.pop());
}),
);
}
return Promise.all(workerProms);
}
set(dbref: FirebaseFirestore.DocumentReference, data: any, options?: FirebaseFirestore.SetOptions): void {
this.batchList2.push({
type: "SET",
ref: dbref,
data,
options,
});
}
delete(dbref: FirebaseFirestore.DocumentReference) {
this.batchList2.push({
type: "DELETE",
ref: dbref,
});
}
}
I tested this, by having 15 concurrent AWS Lambda functions writing 10,000 requests into the database into different collections / documents milliseconds part. I did not get the DEADLINE_EXCEEDED error.
Please see the documentation on firebase.
'deadline-exceeded': Deadline expired before operation could complete. For operations that change the state of the system, this error may be returned even if the operation has completed successfully. For example, a successful response from a server could have been delayed long enough for the deadline to expire.
In our case we are writing a small amount of data and it works most of the time but loosing data is unacceptable. I have not concluded why Firestore fails to write in simple small bits of data.
SOLUTION:
I am using an AWS Lambda function that uses an SQS event trigger.
# This function receives requests from the queue and handles them
# by persisting the survey answers for the respective users.
QuizAnswerQueueReceiver:
handler: app/lambdas/quizAnswerQueueReceiver.handler
timeout: 180 # The SQS visibility timeout should always be greater than the Lambda function’s timeout.
reservedConcurrency: 1 # optional, reserved concurrency limit for this function. By default, AWS uses account concurrency limit
events:
- sqs:
batchSize: 10 # Wait for 10 messages before processing.
maximumBatchingWindow: 60 # The maximum amount of time in seconds to gather records before invoking the function
arn:
Fn::GetAtt:
- SurveyAnswerReceiverQueue
- Arn
environment:
NODE_ENV: ${self:custom.myStage}
I am using a dead letter queue connected to my main queue for failed events.
Resources:
QuizAnswerReceiverQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: ${self:provider.environment.QUIZ_ANSWER_RECEIVER_QUEUE}
# VisibilityTimeout MUST be greater than the lambda functions timeout https://lumigo.io/blog/sqs-and-lambda-the-missing-guide-on-failure-modes/
# The length of time during which a message will be unavailable after a message is delivered from the queue.
# This blocks other components from receiving the same message and gives the initial component time to process and delete the message from the queue.
VisibilityTimeout: 900 # The SQS visibility timeout should always be greater than the Lambda function’s timeout.
# The number of seconds that Amazon SQS retains a message. You can specify an integer value from 60 seconds (1 minute) to 1,209,600 seconds (14 days).
MessageRetentionPeriod: 345600 # The number of seconds that Amazon SQS retains a message.
RedrivePolicy:
deadLetterTargetArn:
"Fn::GetAtt":
- QuizAnswerReceiverQueueDLQ
- Arn
maxReceiveCount: 5 # The number of times a message is delivered to the source queue before being moved to the dead-letter queue.
QuizAnswerReceiverQueueDLQ:
Type: "AWS::SQS::Queue"
Properties:
QueueName: "${self:provider.environment.QUIZ_ANSWER_RECEIVER_QUEUE}DLQ"
MessageRetentionPeriod: 1209600 # 14 days in seconds
If the error is generate after around 10 seconds, probably it's not your internet connetion, it might be that your functions are not returning any promise. In my experience I got the error simply because I had wrapped a firebase set operation(which returns a promise) inside another promise.
You can do this
return db.collection("COL_NAME").doc("DOC_NAME").set(attribs).then(ref => {
var SuccessResponse = {
"code": "200"
}
var resp = JSON.stringify(SuccessResponse);
return resp;
}).catch(err => {
console.log('Quiz Error OCCURED ', err);
var FailureResponse = {
"code": "400",
}
var resp = JSON.stringify(FailureResponse);
return resp;
});
instead of
return new Promise((resolve,reject)=>{
db.collection("COL_NAME").doc("DOC_NAME").set(attribs).then(ref => {
var SuccessResponse = {
"code": "200"
}
var resp = JSON.stringify(SuccessResponse);
return resp;
}).catch(err => {
console.log('Quiz Error OCCURED ', err);
var FailureResponse = {
"code": "400",
}
var resp = JSON.stringify(FailureResponse);
return resp;
});
});

Firestore trigger timeouts occasionally

I have a Cloud Firestore trigger that takes care of adjusting the balance of a user's wallet in my app.
exports.onCreateTransaction = functions.firestore
.document('accounts/{accountId}/transactions/{transactionId}')
.onCreate(async (snap, context) => {
const { accountId, transactionId } = context.params;
const transaction = snap.data();
// See the implementation of alreadyTriggered in the next code block
const alreadyTriggered = await firestoreHelpers.triggers.alreadyTriggered(context);
if (alreadyTriggered) {
return null;
}
if (transaction.status === 'confirmed') {
const accountRef = firestore
.collection('accounts')
.doc(accountId);
const account = (await accountRef.get()).data();
const balance = transaction.type === 'deposit' ?
account.balance + transaction.amount :
account.balance - transaction.amount;
await accountRef.update({ balance });
}
return snap.ref.update({ id: transactionId });
});
As a trigger may actually be called more than once, I added this alreadyTriggered helper function:
const alreadyTriggered = (event) => {
return firestore.runTransaction(async transaction => {
const { eventId } = event;
const metaEventRef = firestore.doc(`metaEvents/${eventId}`);
const metaEvent = await transaction.get(metaEventRef);
if (metaEvent.exists) {
console.error(`Already triggered function for event: ${eventId}`);
return true;
} else {
await transaction.set(metaEventRef, event);
return false;
}
})
};
Most of the time everything works as expected. However, today I got a timeout error which caused data inconsistency in the database.
Function execution took 60005 ms, finished with status: 'timeout'
What was the reason behind this timeout? And how do I make sure that it never happens again, so that my transaction amounts are successfully reflected in the account balance?
That statement about more-than-once execution was a beta limitation, as stated. Cloud Functions is out of beta now. The current guarantee is at-least-once execution by default. you only get multiple possible events if you enable retries in the Cloud console. This is something you should do if you want to make sure your events are processed reliably.
The reason for the timeout may never be certain. There could be any number of reasons. Perhaps there was a hiccup in the network, or a brief amount of downtime somewhere in the system. Retries are supposed to help you recover from these temporary situations by delivering the event potentially many times, so your function can succeed.

How can we load all messages from a single discord channel?

I'm currently working on a self-bot that fetches all images from a channel and then downloads them: when I use my self-bot, the bot doesn't fetch messages that aren't loaded by the client and we can't load all of the messages simultaneously. Is there a way to do that? Something like a command to load all messages from a channel and then do multiple .fetchMessages() to get them all?
Self-Bots might be against the ToS, but iterating through messages in a channel is not, as far as I know. So...
Here's a snippet that will fetch all messages using the new js async generators functionality for efficiency
The snippet:
async function * messagesIterator (channel) {
let before = null
let done = false
while (!done) {
const messages = await channel.messages.fetch({ limit: 100, before })
if (messages.size > 0) {
before = messages.lastKey()
yield messages
} else done = true
}
}
async function * loadAllMessages (channel) {
for await (const messages of messagesIterator(channel)) {
for (const message of messages.values()) yield message
}
}
How it's used:
client.on('ready', async () => {
const targetChannel = client.guilds.cache.first().channels.cache.find(x => x.name === 'test')
// Iterate through all the messages as they're pulled
for await (const message of loadAllMessages(targetChannel)) {
console.log(message.content)
}
})
We can't since it's against ToS. :/ (even if it's a bot I think)

Lambda can't keep up / dropping requests (via API Gateway) if more than 1 req per 30s (Process exited before completing request)

I'm running a relatively simple AWS Function to add a subscription to Stripe.
It runs fine unless I hit it shortly after I just hit it. Just trying to run it in PostMan one after the other fails and returns:
{"errorMessage": "Process exited before completing request"}
The requests are delivered via API Gateway.
Function is configured with 30s timeout and is take ~1300ms to run on the base 128M RAM (issue reproducible # 256M).
I thought this was exactly what Lambda was designed to avoid... I'm second guessing my decision to use Lambda for a (synchronous) mission critical component.
EDIT: As requested, here's the function code:
var stripe = require('stripe');
exports.handler = function (event, context, callback) {
var self = this;
stripe = stripe(getKey(event.stage, 'STRIPE_SECRET_KEY'));
self.createSubscription = createSubscription;
self.validPayload = validPayload;
console.log('event: ', event);
if (self.validPayload(event, context)) {
self.createSubscription(event, stripe, callback, context);
}
/**
* checks that the necessary payload has been received
* if YES: returns true and allows process to continue
* if NO: throws context.fail with useful error message(s)
* operating under custom error code naming convention of
* http code + 3 digit ULM error code
* #param event - from Lambda
* #param context - from Lambda
* #returns {boolean} - whether the payload contains the required data
*/
function validPayload (event, context) {
var errorResponse = {
status: 400,
errors: []
};
if (!event.billing_email) {
errorResponse.errors.push({
code: 400001,
message: "No billing email provided."
})
}
if (!event.plan) {
errorResponse.errors.push({
code: 400002,
message: "No plan was selected."
})
}
if (!event.token) {
errorResponse.errors.push({
code: 400003,
message: "A valid credit card was not provided."
})
}
if (!!errorResponse.errors.length) {
context.fail(JSON.stringify(errorResponse));
return false;
} else {
return true;
}
}
/**
* Creates a new customer & subscription using stripe package method
* if success, executes callback with response data
* if fail, throws context.fail with useful error message(s)
* #param event - from Lambda
* #param stripe - probably not necessary...
* #param callback - from Lambda
* #param context - probably not necessary...
*/
function createSubscription (event, stripe, callback, context) {
stripe.customers.create({
source: event.token,
plan: event.plan,
email: event.billing_email
}, function (err, customer) {
if (err) {
var errorResponse = {
status: 400,
errors: []
};
errorResponse.errors.push({
code: 400004,
message: err.message
});
console.error('Customer/Plan Creation Failed');
callback(JSON.stringify(errorResponse));
} else {
callback(null, {
status: 200,
customer: customer
});
}
});
}
function getKey (stage, keyId) {
var keys = {
STRIPE_SECRET_KEY: {
staging: 'sk_test_123456',
prod: 'sk_live_123456'
}
};
if (stage === 'prod') {
return keys[keyId][stage];
} else {
return keys[keyId]['staging'];
}
}
};
EDIT 2: Dug into CloudWatch and found this error log: TypeError: stripe is not a function at exports.handler (/var/task/exports.js:5:14)
#rowanu is correct, your problem is on this line stripe = stripe(getKey(event.stage, 'STRIPE_SECRET_KEY'));. Since the Lambda stays hot to handle subsequent requests any variables declared outside of the handler function will be seen by each new request that comes in. This should be a simple fix, don't redefine the stripe variable. Something like this would do the trick:
var stripe = require('stripe');
var stripeInstance; // undefined on startup
exports.handler = function (event, context, callback) {
// now define stripeInstance if its not already defined
if(!stripeInstance) {
stripeInstance = stripe(getKey(event.stage, 'STRIPE_SECRET_KEY'));
}
// now all functions will be able to use the same instance of stripe.
// This assumes the event.stage is always the same, if you need a new instance for every request then remove the if statement
// rename all references of stripe to stripeInstance
...
"Process exited before completing request" indicates that your function exited without calling the callback. It is not related to timeouts or throttling.
Usually this indicates that an exception is thrown from a code path that doesn't have adequate error handling.
You will simply need to handle or fix "stripe is not a function at exports.handler (/var/task/exports.js:5:14)" and call the appropriate callback.

How to work around amqplib's Channel#consume odd signature?

I am writing a worker that uses amqplib's Channel#consume method. I want this worker to wait for jobs and process them as soon as they appear in the queue.
I wrote my own module to abstract away ampqlib, here are the relevant functions for getting a connection, setting up the queue and consuming a message:
const getConnection = function(host) {
return amqp.connect(host);
};
const createChannel = function(conn) {
connection = conn;
return conn.createConfirmChannel();
};
const assertQueue = function(channel, queue) {
return channel.assertQueue(queue);
};
const consume = Promise.method(function(channel, queue, processor) {
processor = processor || function(msg) { if (msg) Promise.resolve(msg); };
return channel.consume(queue, processor)
});
const setupQueue = Promise.method(function setupQueue(queue) {
const amqp_host = 'amqp://' + ((host || process.env.AMQP_HOST) || 'localhost');
return getConnection(amqp_host)
.then(conn => createChannel(conn)) // -> returns a `Channel` object
.tap(channel => assertQueue(channel, queue));
});
consumeJob: Promise.method(function consumeJob(queue) {
return setupQueue(queue)
.then(channel => consume(channel, queue))
});
My problem is with Channel#consume's odd signature. From http://www.squaremobius.net/amqp.node/channel_api.html#channel_consume:
#consume(queue, function(msg) {...}, [options, [function(err, ok) {...}]])
The callback is not where the magic happens, the message's processing should actually go in the second argument and that breaks the flow of promises.
This is how I planned on using it:
return queueManager.consumeJob(queue)
.then(msg => {
// do some processing
});
But it doesn't work. If there are no messages in the queue, the promise is rejected and then if a message is dropped in the queue nothing happens. If there is a message, only one message is processed and then the worker stalls because it exited the "processor" function from the Channel#consume call.
How should I go about it? I want to keep the queueManager abstraction so my code is easier to reason about but I don't know how to do it... Any pointers?
As #idbehold said, Promises can only be resolved once. If you want to process messages as they come in, there is no other way than to use this function. Channel#get will only check the queue once and then return; it wouldn't work for a scenario where you need a worker.
just as an option. You can present your application as a stream of some messages(or events). There is a library for this http://highlandjs.org/#examples
Your code should look like this(it isn`t a finished sample, but I hope it illustrates the idea):
let messageStream = _((push, next) => {
consume(queue, (msg) => {
push(null, msg)
})
)
// now you can operate with your stream in functional style
message.map((msg) => msg + 'some value').each((msg) => // do something with msg)
This approach provides you a lot of primitives for synchronization and transformation
http://highlandjs.org/#examples

Resources