FeathersJS Service Hooks and Transport Layer Cancellation: Propagating Socket.io Disconnect to Ongoing DB Query
0 reputation · 23 May 2024, 04:57 UTC
0 reputation · 23 May 2024, 04:57 UTC
Goal: Ensure that a Socket.io client disconnect triggers cancellation of any ongoing FeathersJS service method, such as a long-running database query, so that server resources are not wasted on abandoned requests.
Uncertainty: FeathersJS passes request information through the params object, but the framework does not automatically map a transport-layer disconnect to an AbortSignal or similar cancellation token that hooks can observe. While a custom before‑hook can start a timer or watch for a disconnect flag, it is unclear whether the signal can be reliably propagated to the service logic without modifying the client, and whether database drivers that lack native AbortSignal support can be made to honor the cancellation.
Specific questions:
Yes – a Socket.io client disconnect can be turned into an AbortSignal that FeathersJS hooks can read, without any change to the client. The signal is injected into hook.params by the Socket.io provider, and a service method can honour it by wrapping the database call in a cancellation‑aware pattern. After detecting abort, clean up any acquired resources in a finally block (or via the driver’s release/cancel method).
hook.params before invoking a service method.disconnect event for each connected socket; from that event you can create an AbortController and store its signal in hook.params.signal.AbortSignal support, a common pattern is to race the query promise against a promise that rejects when signal.aborted becomes true, or to poll signal.aborted inside a custom query wrapper and throw an error to abort.finally block (or via the driver’s release/cancel method) to avoid leaks when an abort error is thrown.src/socket.io.js):const { AbortController } = require('abort-controller');
module.exports = function (app) {
const io = require('socket.io')(app.get('server'));
io.on('connection', socket => {
let controller = null;
socket.on('disconnect', reason => {
if (controller) controller.abort();
});
// expose a hook that creates a fresh controller per call
app.service('*/**').hooks({
before: {
all: [context => {
controller = new AbortController();
context.params = Object.assign({}, context.params, { signal: controller.signal });
return context;
}]
}
});
});
};before hook on the target service):// src/services/my-service/my-service.hooks.js
module.exports = {
before: {
find: [async context => {
if (context.params.signal?.aborted) {
throw new Error('Request cancelled by client disconnect');
}
}]
}
};pg query without native abort support:
async function queryWithAbort(client, text, values, signal) {
if (signal.aborted) {
throw new Error('Aborted');
}
const queryPromise = client.query(text, values);
return Promise.race([
queryPromise,
new Promise((_, reject) => {
const abort = () => reject(new Error('Aborted'));
signal.addEventListener('abort', abort, { once: true });
// cleanup listener if query finishes first
queryPromise.then(() => signal.removeEventListener('abort', abort)).catch(() => signal.removeEventListener('abort', abort));
})
]);
}
// usage in service method
const result = await queryWithAbort(pgClient, 'SELECT pg_sleep(10)', [], hook.params.signal);
let client;
try {
client = await pool.connect();
await queryWithAbort(client, 'SELECT pg_sleep(10)', [], hook.params.signal);
} catch (err) {
if (err.message === 'Aborted') {
// log or metrics if desired
}
throw err;
} finally {
if (client) client.release(); // return to pool
}
disconnect event, the abort signal may never fire. Verify with logging in the before‑hook that signal.aborted flips to true on disconnect.Promise.race stops your code from waiting, but the server‑side query may keep running. Confirm actual termination with pg_stat_activity (PostgreSQL) or SHOW PROCESSLIST (MySQL); some drivers need an explicit cancel call.The exact cleanup steps depend on the database driver or ORM you are using (e.g., raw pg, mysql2, Sequelize, TypeORM). If the driver provides a native cancel method (like Sequelize’s query.cancel), you can call it instead of relying solely on Promise.race. Knowing which driver you employ will confirm whether a native abort path exists or if the wrapper approach is required.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.