No tarball for gmail
This commit is contained in:
commit
16c9043bd0
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.git/
|
||||||
|
.node_modules/
|
||||||
|
.gitignore
|
||||||
|
.editorconfig
|
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.{js,json,yml}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
FROM node:latest
|
||||||
|
|
||||||
|
WORKDIR /opt/proj/
|
||||||
|
COPY . .
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
EXPOSE 50051
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "server"]
|
||||||
|
|
||||||
|
|
15
README.md
Normal file
15
README.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# PubSub server
|
||||||
|
|
||||||
|
## Quick and easy
|
||||||
|
1. run `sudo docker build -t pubsub .`
|
||||||
|
2. `sudo docker run --rm -p 50051:50051 -it pubsub`
|
||||||
|
|
||||||
|
## Building (with dev dependencies)
|
||||||
|
`npm install`
|
||||||
|
|
||||||
|
## Running
|
||||||
|
`npm start` (runs on port 50051 by default)
|
||||||
|
`npm start -- -p 5555` to run on port 5555 for example
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
`npm test`
|
33
grpc/index.js
Normal file
33
grpc/index.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import grpc from '@grpc/grpc-js';
|
||||||
|
import proto from '@grpc/proto-loader';
|
||||||
|
|
||||||
|
export function loadPubSub() {
|
||||||
|
const PROTO_PATH = 'grpc/service.proto';
|
||||||
|
const packageDefinition = proto.loadSync(PROTO_PATH, {});
|
||||||
|
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
|
||||||
|
return protoDescriptor.pubsub;
|
||||||
|
}
|
||||||
|
export function setupgRPC(handler) {
|
||||||
|
const server = new grpc.Server();
|
||||||
|
const pubsub = loadPubSub();
|
||||||
|
server.addService(pubsub.PubSub.service, handler)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClient(address) {
|
||||||
|
const pubsub = loadPubSub()
|
||||||
|
return new pubsub.PubSub(address, grpc.credentials.createInsecure());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startServer(
|
||||||
|
address,
|
||||||
|
handler,
|
||||||
|
logger,
|
||||||
|
) {
|
||||||
|
const server = setupgRPC(handler)
|
||||||
|
server.bindAsync(address, grpc.ServerCredentials.createInsecure(), () => {
|
||||||
|
logger.info(`Started server on ${address}`)
|
||||||
|
server.start();
|
||||||
|
});
|
||||||
|
return server
|
||||||
|
}
|
27
grpc/service.proto
Normal file
27
grpc/service.proto
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
package pubsub;
|
||||||
|
|
||||||
|
service PubSub {
|
||||||
|
|
||||||
|
rpc Publish(PublishMessage) returns (PublishResponse) {}
|
||||||
|
|
||||||
|
rpc Consume(Consumer) returns (Message) {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
message PublishResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
message Consumer {
|
||||||
|
string topic = 1;
|
||||||
|
uint64 timeout_ms = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PublishMessage {
|
||||||
|
string topic = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Message {
|
||||||
|
string message = 1;
|
||||||
|
}
|
25
logger/index.js
Normal file
25
logger/index.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import winston from "winston";
|
||||||
|
|
||||||
|
export default function ({
|
||||||
|
service = "pubsub",
|
||||||
|
postfix = "",
|
||||||
|
level = "info",
|
||||||
|
} = {}) {
|
||||||
|
return winston.createLogger({
|
||||||
|
level,
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.simple()
|
||||||
|
),
|
||||||
|
defaultMeta: { service: `${service}:${postfix}` },
|
||||||
|
transports: [new winston.transports.Console()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//Gets logger's level from CLI input, defaults to 'info'
|
||||||
|
export function getLevel(num) {
|
||||||
|
const [logLevel] = Object.entries(winston.config.npm.levels)
|
||||||
|
.filter(([name, value]) => value === num)
|
||||||
|
.shift() || ["info"];
|
||||||
|
return logLevel;
|
||||||
|
}
|
3979
package-lock.json
generated
Normal file
3979
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "temporal",
|
||||||
|
"packageManager": "yarn@3.2.2",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@grpc/grpc-js": "^1.6.8",
|
||||||
|
"@grpc/proto-loader": "^0.7.0",
|
||||||
|
"winston": "^3.8.1",
|
||||||
|
"yargs": "^17.5.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server/",
|
||||||
|
"test": "ava"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"ava": "^4.3.1",
|
||||||
|
"prettier": "^2.7.1"
|
||||||
|
}
|
||||||
|
}
|
38
server/index.js
Normal file
38
server/index.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {default as createLogger, getLevel} from "../logger/index.js";
|
||||||
|
import yargs from "yargs/yargs";
|
||||||
|
|
||||||
|
import { startServer } from "../grpc/index.js";
|
||||||
|
|
||||||
|
import pubsub from "./pubsub.js";
|
||||||
|
|
||||||
|
const argv = yargs(process.argv.slice(2))
|
||||||
|
.option("port", {
|
||||||
|
alias: "p",
|
||||||
|
demandOption: true,
|
||||||
|
default: 50051,
|
||||||
|
describe: "Listen on this port for incoming client connections",
|
||||||
|
type: "number",
|
||||||
|
})
|
||||||
|
.option("bind", {
|
||||||
|
alias: "b",
|
||||||
|
demandOption: true,
|
||||||
|
default: "0.0.0.0",
|
||||||
|
describe: "Which IP to bind to",
|
||||||
|
type: "string",
|
||||||
|
})
|
||||||
|
.option("verbose", {
|
||||||
|
alias: "v",
|
||||||
|
default: 2,
|
||||||
|
describe: "Verbosity level, 0 to 6, 0=error, 1=warn, 2=info, 3+=debug",
|
||||||
|
type: "number",
|
||||||
|
})
|
||||||
|
.help()
|
||||||
|
.alias("help", "h").argv;
|
||||||
|
|
||||||
|
const logger = createLogger({level: getLevel(argv.verbose), postfix: "server"})
|
||||||
|
const address = `${argv.bind}:${argv.port}`;
|
||||||
|
const server = startServer(address, pubsub({ logger }), logger);
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
logger.info("Shutting down due to SIGINT")
|
||||||
|
server.forceShutdown()
|
||||||
|
});
|
67
server/pubsub.js
Normal file
67
server/pubsub.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import _logger from "../logger/index.js";
|
||||||
|
|
||||||
|
//Creates a consumer that automatically removes itsefl upon timing out
|
||||||
|
function consumer(timeoutMs, cb, call, remove) {
|
||||||
|
const c = {
|
||||||
|
cb,
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
remove(c);
|
||||||
|
cb({
|
||||||
|
message: "Timedout",
|
||||||
|
status: 4, //Deadline exceeded
|
||||||
|
});
|
||||||
|
}, timeoutMs);
|
||||||
|
call.on("cancelled", () => remove(c));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next(topic) {
|
||||||
|
const consumer = topic.consumers.values().next().value;
|
||||||
|
const msg = topic.queue.shift();
|
||||||
|
if (consumer && msg) {
|
||||||
|
//Fire and forget. At-most-once semantics.
|
||||||
|
topic.consumers.delete(consumer);
|
||||||
|
consumer.cb(undefined, { message: msg });
|
||||||
|
} else {
|
||||||
|
if (msg) {
|
||||||
|
topic.queue.unshift(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function create({ logger = _logger("impl") } = {}) {
|
||||||
|
const topics = {};
|
||||||
|
|
||||||
|
//If logging is above info, display the content of the queues every 5s for debuggin
|
||||||
|
if (logger.levels[logger.level] >= 4) {
|
||||||
|
setInterval(() => {
|
||||||
|
console.log(topics);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopic(topic) {
|
||||||
|
// Consumers are stored in a Map to allow O(1) deletion on timeout while
|
||||||
|
// allowing iteration in order of insertion
|
||||||
|
return (topics[topic] = topics[topic] || {
|
||||||
|
queue: [],
|
||||||
|
consumers: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
publish: (call, cb) => {
|
||||||
|
const topic = getTopic(call.request.topic);
|
||||||
|
topic.queue.push(call.request.message);
|
||||||
|
cb(undefined, {});
|
||||||
|
next(topic);
|
||||||
|
},
|
||||||
|
consume: (call, cb) => {
|
||||||
|
const { topic: topicStr, timeoutMs } = call.request;
|
||||||
|
const topic = getTopic(topicStr);
|
||||||
|
const c = consumer(timeoutMs, cb, call, (c) => topic.consumers.delete(c));
|
||||||
|
topic.consumers.set(c, c);
|
||||||
|
next(topic);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
165
server/pubsub.test.js
Normal file
165
server/pubsub.test.js
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import test from "ava";
|
||||||
|
import pubsub from "./pubsub.js";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
//Should mock those (TODO)
|
||||||
|
function publishCall(topic, message) {
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
topic,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeCall(topic, timeoutMs = 10000) {
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
topic,
|
||||||
|
timeoutMs,
|
||||||
|
},
|
||||||
|
on: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach((t) => {
|
||||||
|
t.context.pubsub = pubsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("Published messages are available to be consumed", async (t) => {
|
||||||
|
const { pubsub } = t.context;
|
||||||
|
t.plan(2);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
pubsub.publish(publishCall("topic", "message"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Published messages automatically create topics", async (t) => {
|
||||||
|
const { pubsub } = t.context;
|
||||||
|
t.plan(4);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
pubsub.publish(publishCall("topic", "message"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
});
|
||||||
|
pubsub.publish(publishCall("topic2", "message2"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message");
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic2"), (error, { message }) => {
|
||||||
|
t.is(message, "message2");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Consumer waits for a published message", async (t) => {
|
||||||
|
const { pubsub } = t.context;
|
||||||
|
t.plan(2);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message");
|
||||||
|
});
|
||||||
|
pubsub.publish(publishCall("topic", "message"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Consumer timeout if no message published", async (t) => {
|
||||||
|
const { pubsub } = t.context;
|
||||||
|
t.plan(2);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
pubsub.consume(consumeCall("topic", 0), (error, nothing) => {
|
||||||
|
t.is(nothing, undefined);
|
||||||
|
t.deepEqual(error, { message: "Timedout", status: 4 });
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Multiple consumers wait for multiple messages, FIFO", async (t) => {
|
||||||
|
const { pubsub } = t.context;
|
||||||
|
t.plan(6);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message1");
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message2");
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message3");
|
||||||
|
});
|
||||||
|
pubsub.publish(publishCall("topic", "message1"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
});
|
||||||
|
pubsub.publish(publishCall("topic", "message2"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
});
|
||||||
|
pubsub.publish(publishCall("topic", "message3"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Multiple consumers, only consumers that have not timed out get a message, FIFO", async (t) => {
|
||||||
|
const { pubsub } = t.context;
|
||||||
|
t.plan(8);
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message1");
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic", 0), (error, nothing) => {
|
||||||
|
t.is(nothing, undefined);
|
||||||
|
t.deepEqual(error, { message: "Timedout", status: 4 });
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message2");
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message3");
|
||||||
|
});
|
||||||
|
//Allow consumer to timeout
|
||||||
|
await sleep(1);
|
||||||
|
pubsub.publish(publishCall("topic", "message1"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
});
|
||||||
|
pubsub.publish(publishCall("topic", "message2"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
});
|
||||||
|
pubsub.publish(publishCall("topic", "message3"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Multiple consumers trying to read a message, only the first will succeed", async (t) => {
|
||||||
|
const { pubsub } = t.context;
|
||||||
|
t.plan(4);
|
||||||
|
await new Promise(async (resolve) => {
|
||||||
|
pubsub.publish(publishCall("topic", "message1"), (error) => {
|
||||||
|
t.is(error, undefined);
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic"), (error, { message }) => {
|
||||||
|
t.is(message, "message1");
|
||||||
|
});
|
||||||
|
pubsub.consume(consumeCall("topic", 100), (error, nothing) => {
|
||||||
|
t.is(nothing, undefined);
|
||||||
|
t.deepEqual(error, { message: "Timedout", status: 4 });
|
||||||
|
resolve()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user