├── demo ├── style.css ├── client │ ├── components │ │ ├── CascadeChart.scss │ │ ├── RetryLevelTextField.tsx │ │ ├── MessageSlider.tsx │ │ ├── RadioButtonGroup.tsx │ │ ├── Radiogroup.tsx │ │ ├── GettingStarted.tsx │ │ ├── About.tsx │ │ ├── Features.tsx │ │ ├── CascadeChart.tsx │ │ └── NavBar.tsx │ ├── index.tsx │ ├── containers │ │ ├── AboutContainer.tsx │ │ ├── FeaturesContainer.tsx │ │ ├── GettingStartedContainer.tsx │ │ ├── OptionContainer.scss │ │ └── OptionContainer.tsx │ ├── socket.ts │ ├── App.scss │ └── App.tsx ├── assets │ ├── favicon.ico │ ├── Robert_Du.jpg │ ├── SeungJoonLee.jpg │ ├── Davette_Bryan.jpg │ ├── Michael_Weber.png │ └── favIconLarger.png ├── index.html ├── tsconfig.json ├── webpack.config.js ├── docker-compose.yml ├── server │ ├── server.ts │ ├── websocket.ts │ └── controllers │ │ └── cascadeController.ts └── package.json ├── .dockerignore ├── .gitignore ├── docs ├── favIconLarger.png ├── kafka-cascade-flow.png ├── styles │ ├── iframe.css │ ├── reset.css │ ├── prettify-jsdoc.css │ └── prettify-tomorrow.css ├── style.css ├── scripts │ ├── linenumber.js │ ├── search.js │ └── app.min.js ├── docs.md ├── index.ts.html └── index.html ├── dist ├── src │ ├── kafkaInterface.js │ ├── util │ │ ├── queue.d.ts │ │ └── queue.js │ ├── cascadeConsumer.d.ts │ ├── cascadeProducer.d.ts │ ├── kafkaInterface.d.ts │ ├── cascadeConsumer.js │ ├── cascadeService.d.ts │ ├── cascadeService.js │ └── cascadeProducer.js ├── index.d.ts └── index.js ├── babel.config.js ├── Dockerfile ├── .npmignore ├── tsconfig.json ├── jsdoc.json ├── .github └── workflows │ └── node.js.yml ├── LICENSE ├── kafka-cascade ├── src │ ├── util │ │ └── queue.ts │ ├── kafkaInterface.ts │ ├── cascadeConsumer.ts │ ├── cascadeService.ts │ └── cascadeProducer.ts └── index.ts ├── __tests__ ├── cascade.util.queue.test.ts ├── cascade.metadata.test.ts ├── cascade.test.ts ├── cascade.retrystrat.test.ts ├── cascade.routes.test.ts ├── cascade.mockclient.test.ts └── cascade.events.test.ts ├── package.json ├── docker-compose.yml └── README.md /demo/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | demo/dist 4 | *.zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | demo/dist 4 | .env 5 | *.zip -------------------------------------------------------------------------------- /demo/client/components/CascadeChart.scss: -------------------------------------------------------------------------------- 1 | .cascadeChart { 2 | width:100%; 3 | } -------------------------------------------------------------------------------- /demo/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Kafka-Cascade/HEAD/demo/assets/favicon.ico -------------------------------------------------------------------------------- /docs/favIconLarger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Kafka-Cascade/HEAD/docs/favIconLarger.png -------------------------------------------------------------------------------- /demo/assets/Robert_Du.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Kafka-Cascade/HEAD/demo/assets/Robert_Du.jpg -------------------------------------------------------------------------------- /dist/src/kafkaInterface.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | -------------------------------------------------------------------------------- /demo/assets/SeungJoonLee.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Kafka-Cascade/HEAD/demo/assets/SeungJoonLee.jpg -------------------------------------------------------------------------------- /docs/kafka-cascade-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Kafka-Cascade/HEAD/docs/kafka-cascade-flow.png -------------------------------------------------------------------------------- /demo/assets/Davette_Bryan.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Kafka-Cascade/HEAD/demo/assets/Davette_Bryan.jpg -------------------------------------------------------------------------------- /demo/assets/Michael_Weber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Kafka-Cascade/HEAD/demo/assets/Michael_Weber.png -------------------------------------------------------------------------------- /demo/assets/favIconLarger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Kafka-Cascade/HEAD/demo/assets/favIconLarger.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], 3 | }; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | WORKDIR /usr/src/app 3 | COPY . /usr/src/app/ 4 | RUN cd demo 5 | RUN npm install 6 | ENTRYPOINT ["npm", "start"] 7 | EXPOSE 3000 4000 -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | demo 3 | babel.config.js 4 | docker-compose.yml 5 | Dockerfile 6 | *.zip 7 | docs 8 | jsdoc.json 9 | __tests__ 10 | .dockerignore 11 | .env -------------------------------------------------------------------------------- /demo/client/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import { App } from "./App"; 4 | 5 | 6 | render( 7 | , 8 | document.getElementById("root") 9 | ); -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Kafka Cascade 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/styles/iframe.css: -------------------------------------------------------------------------------- 1 | .bd__button { 2 | padding: 10px 0; 3 | text-align: right; 4 | } 5 | .bd__button > a{ 6 | font-weight: 100; 7 | text-decoration: none; 8 | color: #BDC3CB; 9 | font-family: sans-serif; 10 | } 11 | .bd__button > a:hover { 12 | color: #798897; 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./dist" 7 | }, 8 | "exclude": [ 9 | "node_modules", 10 | "__tests__", 11 | "dist", 12 | "demo" 13 | ] 14 | 15 | } -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import CascadeService from './src/cascadeService'; 2 | import CascadeProducer from './src/cascadeProducer'; 3 | import CascadeConsumer from './src/cascadeConsumer'; 4 | import * as Types from './src/kafkaInterface'; 5 | export { CascadeService, CascadeProducer, CascadeConsumer, Types, }; 6 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "module": "commonjs", 5 | "target": "es5", 6 | "jsx": "react", 7 | "esModuleInterop": true, 8 | "allowJs": true 9 | }, 10 | "exclude": [ 11 | "node_modules", 12 | "client" 13 | ] 14 | } -------------------------------------------------------------------------------- /dist/src/util/queue.d.ts: -------------------------------------------------------------------------------- 1 | export default class Queue { 2 | head: QueueNode | null; 3 | tail: QueueNode | null; 4 | length: number; 5 | constructor(); 6 | push(val: Type): number; 7 | shift(): Type | undefined; 8 | } 9 | declare class QueueNode { 10 | value: T; 11 | next: QueueNode | null; 12 | prev: QueueNode | null; 13 | constructor(value: T); 14 | } 15 | export {}; 16 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | .top-nav { 2 | background-color: #440057; 3 | } 4 | 5 | .navbar-item { 6 | color: white; 7 | } 8 | 9 | .top-nav .menu .navigation .link { 10 | color: white; 11 | } 12 | 13 | .navigation h1 { 14 | font-family: "Roboto", "Helvetica", "Arial", sans-serif; 15 | } 16 | 17 | .sidebar { 18 | background-color: rgba(225, 218, 232); 19 | } 20 | 21 | .side-nav { 22 | background-color: rgba(225, 218, 232); 23 | } 24 | 25 | .logo { 26 | display: flex; 27 | width: 400px; 28 | } 29 | 30 | .logo .image { 31 | height:50px; 32 | width:46px; 33 | } 34 | -------------------------------------------------------------------------------- /demo/client/containers/AboutContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | createStyles, makeStyles, Container, 4 | } from '@material-ui/core'; 5 | import About from '../components/About' 6 | 7 | const useStyles = makeStyles(() => createStyles({ 8 | container: { 9 | display: 'flex', 10 | } 11 | })); 12 | 13 | const AboutContainer: FC = () => { 14 | 15 | const classes = useStyles(); 16 | 17 | return ( 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default AboutContainer; -------------------------------------------------------------------------------- /demo/client/containers/FeaturesContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | createStyles, makeStyles, Container, 4 | } from '@material-ui/core'; 5 | import Features from '../components/Features'; 6 | 7 | const useStyles = makeStyles(() => createStyles({ 8 | container: { 9 | display: 'flex', 10 | justifyContent: 'center', 11 | alignContent: 'space-between', 12 | }, 13 | })); 14 | 15 | const FeaturesContainer: FC = () => { 16 | 17 | const classes = useStyles(); 18 | 19 | return ( 20 | 21 | 22 | 23 | ); 24 | 25 | }; 26 | 27 | export default FeaturesContainer; -------------------------------------------------------------------------------- /demo/client/containers/GettingStartedContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | createStyles, makeStyles, Container, 4 | } from '@material-ui/core'; 5 | import GettingStarted from '../components/GettingStarted' 6 | 7 | const useStyles = makeStyles(() => createStyles({ 8 | container: { 9 | display: 'flex', 10 | justifyContent: 'center', 11 | alignContent: 'space-between', 12 | }, 13 | })); 14 | 15 | const GettingStartedContainer: FC = () => { 16 | const classes = useStyles(); 17 | 18 | return ( 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default GettingStartedContainer; -------------------------------------------------------------------------------- /dist/src/cascadeConsumer.d.ts: -------------------------------------------------------------------------------- 1 | declare const EventEmitter: any; 2 | import * as Types from './kafkaInterface'; 3 | declare class CascadeConsumer extends EventEmitter { 4 | consumer: Types.ConsumerInterface; 5 | topic: string; 6 | groupId: string; 7 | fromBeginning: boolean; 8 | constructor(kafkaInterface: Types.KafkaInterface, topic: string, groupId: string, fromBeginning?: boolean); 9 | connect(): Promise; 10 | run(serviceCB: Types.ServiceCallback, successCB: Types.RouteCallback, rejectCB: Types.RouteCallback): Promise; 11 | disconnect(): Promise; 12 | stop(): Promise; 13 | pause(): Promise; 14 | resume(): Promise; 15 | on(event: string, callback: (arg: any) => any): void; 16 | } 17 | export default CascadeConsumer; 18 | -------------------------------------------------------------------------------- /docs/scripts/linenumber.js: -------------------------------------------------------------------------------- 1 | /*global document */ 2 | 3 | (function() { 4 | var source = document.getElementsByClassName('prettyprint source linenums'); 5 | var i = 0; 6 | var lineNumber = 0; 7 | var lineId; 8 | var lines; 9 | var totalLines; 10 | var anchorHash; 11 | 12 | if (source && source[0]) { 13 | anchorHash = document.location.hash.substring(1); 14 | lines = source[0].getElementsByTagName('li'); 15 | totalLines = lines.length; 16 | 17 | for (; i < totalLines; i++) { 18 | lineNumber++; 19 | lineId = 'line' + lineNumber; 20 | lines[i].id = lineId; 21 | if (lineId === anchorHash) { 22 | lines[i].className += ' selected'; 23 | } 24 | } 25 | } 26 | })(); 27 | -------------------------------------------------------------------------------- /demo/client/components/RetryLevelTextField.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC, useState, useEffect} from 'react'; 2 | import TextField from '@material-ui/core/TextField'; 3 | import Box from "@material-ui/core/Box"; 4 | 5 | //displays retry level input field 6 | export const RetryLevelTextField: FC = (props:any) => { 7 | return ( 8 | 9 | {props.updateLimitArrayHandler(event, props.index)}} 21 | onWheel={(event:any) => {event.target.blur()}} 22 | /> 23 | 24 | ) 25 | } -------------------------------------------------------------------------------- /demo/client/components/MessageSlider.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import Slider from '@material-ui/core/Slider'; 3 | import Typography from '@material-ui/core/Typography'; 4 | 5 | function valuetext(value: number) { 6 | return `${value}`; 7 | } 8 | 9 | // updates the number of messages per second 10 | export const MessageSlider: FC = (props:any) => { 11 | const {messagesPerSecond, setMessagesPerSecondHandler} = props; 12 | 13 | return( 14 |
15 | 16 | Messages per Second 17 | 18 | 29 |
30 | ) 31 | } -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kafka-Cascade Documentation", 3 | "opts": { 4 | "readme": "./docs/docs.md", 5 | "template": "node_modules/better-docs" 6 | }, 7 | "tags": { 8 | "allowUnknownTags": ["optional"] 9 | }, 10 | "plugins": [ 11 | "node_modules/better-docs/typescript" 12 | ], 13 | "source": { 14 | "includePattern": "\\.(jsx|js|ts|tsx)$" 15 | }, 16 | "templates": { 17 | "name": "Kafka-Cascade Documentation", 18 | "better-docs": { 19 | "name": "Kafka-Cascade Documentation", 20 | "title": "Kafka-Cascade Documentation", 21 | "logo": "favIconLarger.png", 22 | "css": "style.css", 23 | "hideGenerator": false, 24 | "navLinks": [ 25 | { 26 | 27 | "label": "Github", 28 | "href": "https://github.com/oslabs-beta/Kafka-Cascade" 29 | }, 30 | { 31 | "label": "Home", 32 | "href": "/" 33 | } 34 | ] 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - name: npm install 30 | run: npm install 31 | - name: npm test 32 | run: npm test 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dist/src/util/queue.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | class Queue { 4 | constructor() { 5 | this.head = null; 6 | this.tail = null; 7 | this.length = 0; 8 | } 9 | push(val) { 10 | if (!this.head) 11 | this.head = this.tail = new QueueNode(val); 12 | else { 13 | this.tail.next = new QueueNode(val); 14 | this.tail.next.prev = this.tail; 15 | this.tail = this.tail.next; 16 | } 17 | return ++this.length; 18 | } 19 | shift() { 20 | if (!this.head) 21 | return undefined; 22 | const cache = this.head.value; 23 | if (this.head === this.tail) { 24 | this.head = null; 25 | this.tail = null; 26 | } 27 | else { 28 | this.head = this.head.next; 29 | this.head.prev = null; 30 | } 31 | this.length--; 32 | return cache; 33 | } 34 | } 35 | exports.default = Queue; 36 | class QueueNode { 37 | constructor(value) { 38 | this.value = value; 39 | this.next = null; 40 | this.prev = null; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /docs/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* reset css */ 2 | html, body, div, span, applet, object, iframe, 3 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 4 | a, abbr, acronym, address, big, cite, code, 5 | del, dfn, em, img, ins, kbd, q, s, samp, 6 | small, strike, strong, sub, sup, tt, var, 7 | b, u, i, center, 8 | dl, dt, dd, ol, ul, li, 9 | fieldset, form, label, legend, 10 | table, caption, tbody, tfoot, thead, tr, th, td, 11 | article, aside, canvas, details, embed, 12 | figure, figcaption, footer, header, hgroup, 13 | menu, nav, output, ruby, section, summary, 14 | time, mark, audio, video { 15 | margin: 0; 16 | padding: 0; 17 | border: 0; 18 | font-size: 100%; 19 | font: inherit; 20 | vertical-align: baseline; 21 | } 22 | /* HTML5 display-role reset for older browsers */ 23 | article, aside, details, figcaption, figure, 24 | footer, header, hgroup, menu, nav, section { 25 | display: block; 26 | } 27 | body { 28 | line-height: 1; 29 | } 30 | ol, ul { 31 | list-style: none; 32 | } 33 | blockquote, q { 34 | quotes: none; 35 | } 36 | blockquote:before, blockquote:after, 37 | q:before, q:after { 38 | content: ''; 39 | content: none; 40 | } 41 | table { 42 | border-collapse: collapse; 43 | border-spacing: 0; 44 | } 45 | -------------------------------------------------------------------------------- /kafka-cascade/src/util/queue.ts: -------------------------------------------------------------------------------- 1 | export default class Queue { 2 | head: QueueNode | null; 3 | tail: QueueNode | null; 4 | length: number; 5 | 6 | constructor() { 7 | this.head = null; 8 | this.tail = null; 9 | this.length = 0; 10 | } 11 | 12 | push(val: Type) { 13 | if(!this.head) this.head = this.tail = new QueueNode(val); 14 | else { 15 | this.tail.next = new QueueNode(val); 16 | this.tail.next.prev = this.tail; 17 | this.tail = this.tail.next; 18 | } 19 | 20 | return ++this.length; 21 | } 22 | 23 | shift():Type|undefined { 24 | if(!this.head) return undefined; 25 | const cache = this.head.value; 26 | 27 | if(this.head === this.tail) { 28 | this.head = null; 29 | this.tail = null; 30 | } 31 | else { 32 | this.head = this.head.next; 33 | this.head.prev = null; 34 | } 35 | 36 | this.length--; 37 | return cache; 38 | } 39 | } 40 | 41 | class QueueNode { 42 | value: T; 43 | next: QueueNode | null; 44 | prev: QueueNode | null; 45 | 46 | constructor(value: T) { 47 | this.value = value; 48 | this.next = null; 49 | this.prev = null; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const Dotenv = require('dotenv-webpack'); 4 | 5 | module.exports = { 6 | mode: process.env.NODE_ENV || 'development', 7 | devtool: 'inline-source-map', 8 | entry: './client/index.tsx', 9 | output: { 10 | path: path.resolve(__dirname, 'dist'), 11 | publicPath: '/', 12 | filename: 'bundle.js', 13 | clean: true, 14 | }, 15 | plugins: [ 16 | new HtmlWebpackPlugin({ 17 | template: './index.html', 18 | }), 19 | new Dotenv(), 20 | ], 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.tsx?$/, 25 | loader: 'ts-loader', 26 | exclude: /node_modules/, 27 | options: { 28 | transpileOnly: true, 29 | }, 30 | }, 31 | { 32 | test: /\.s?css$/, 33 | use: ['style-loader', 'css-loader', 'sass-loader'], 34 | }, 35 | ], 36 | }, 37 | resolve: { 38 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 39 | }, 40 | devServer: { 41 | port: 3000, 42 | contentBase: path.resolve(__dirname, 'dist'), 43 | publicPath: '/dist/', 44 | proxy: { 45 | '/': 'http://localhost:3000', 46 | }, 47 | compress: true, 48 | hot: true, 49 | historyApiFallback: true, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /__tests__/cascade.util.queue.test.ts: -------------------------------------------------------------------------------- 1 | import Queue from '../kafka-cascade/src/util/queue'; 2 | 3 | describe('Queue tests', () => { 4 | it('Can push to queue', () => { 5 | const queue = new Queue(); 6 | queue.push(5); 7 | expect(queue).toHaveLength(1); 8 | expect(queue.head.value).toBe(5); 9 | }); 10 | 11 | it('Can shift from queue', () => { 12 | const queue = new Queue(); 13 | queue.push(5); 14 | expect(queue.shift()).toBe(5); 15 | expect(queue).toHaveLength(0); 16 | expect(queue.head).toBeNull(); 17 | expect(queue.tail).toBeNull(); 18 | }); 19 | 20 | it('Returns undefined when shifting on an empty list', () => { 21 | const queue = new Queue(); 22 | expect(queue.shift()).toBeUndefined(); 23 | }); 24 | 25 | it('Can push and shift multiple values', () => { 26 | const queue = new Queue(); 27 | for(let i = 1; i <= 10; i++) queue.push(i); 28 | expect(queue).toHaveLength(10); 29 | expect(queue.shift()).toBe(1); 30 | expect(queue.shift()).toBe(2); 31 | expect(queue.shift()).toBe(3); 32 | expect(queue.shift()).toBe(4); 33 | expect(queue.shift()).toBe(5); 34 | expect(queue).toHaveLength(5); 35 | expect(queue.push(11)).toBe(6); 36 | expect(queue.shift()).toBe(6); 37 | expect(queue).toHaveLength(5); 38 | }); 39 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kafka-cascade", 3 | "version": "1.0.6", 4 | "description": "Message Reprocessing for KafkaJS", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "start": "cd demo; npm start", 8 | "build": "tsc", 9 | "dev": "nodemon ./dist/test.js", 10 | "test": "jest --verbose", 11 | "demo": "cd demo; ts-node \"./server/server.ts\" & docker-compose up", 12 | "docs": "jsdoc -c jsdoc.json -d ./docs/ ./kafka-cascade/index.ts ./kafka-cascade/src/cascadeService.ts" 13 | }, 14 | "keywords": [ 15 | "Kafka", 16 | "KafkaJS", 17 | "Message Reprocessing", 18 | "Dead Letter Queue" 19 | ], 20 | "author": "Davette Byran, Michael Weber, Robert Du, Seung Joon Lee", 21 | "license": "MIT", 22 | "homepage": "https://www.npmjs.com/package/kafka-cascade", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/oslabs-beta/Kafka-Cascade" 26 | }, 27 | "dependencies": { 28 | "kafkajs": "^1.15.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.14.6", 32 | "@babel/preset-env": "^7.14.7", 33 | "@babel/preset-typescript": "^7.14.5", 34 | "@types/express": "^4.17.12", 35 | "@types/jest": "^26.0.23", 36 | "@types/node": "^15.12.5", 37 | "babel-jest": "^27.0.6", 38 | "babel-loader": "^8.2.2", 39 | "better-docs": "^2.3.2", 40 | "jest": "^27.0.6", 41 | "jsdoc": "^3.6.7", 42 | "ts-jest": "^27.0.3", 43 | "ts-node": "^10.0.0", 44 | "typescript": "^4.3.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /dist/src/cascadeProducer.d.ts: -------------------------------------------------------------------------------- 1 | declare const EventEmitter: any; 2 | import * as Types from './kafkaInterface'; 3 | import Queue from './util/queue'; 4 | declare class CascadeProducer extends EventEmitter { 5 | producer: Types.ProducerInterface; 6 | admin: Types.AdminInterface; 7 | topic: string; 8 | dlqCB: Types.RouteCallback; 9 | retryTopics: string[]; 10 | paused: boolean; 11 | pausedQueue: Queue<{ 12 | msg: Types.KafkaConsumerMessageInterface; 13 | status: string; 14 | }>; 15 | sendStorage: {}; 16 | routes: Types.ProducerRoute[]; 17 | constructor(kafka: Types.KafkaInterface, topic: string, dlqCB: Types.RouteCallback); 18 | connect(): Promise; 19 | disconnect(): Promise; 20 | pause(): void; 21 | resume(): Promise; 22 | stop(): Promise; 23 | send(msg: Types.KafkaConsumerMessageInterface, status: string): Promise; 24 | sendTimeout(id: string, msg: Types.KafkaProducerMessageInterface, retries: number, route: Types.ProducerRoute): Promise; 25 | sendBatch(msg: Types.KafkaProducerMessageInterface, retries: number, route: Types.ProducerRoute): Promise; 26 | setDefaultRoute(count: number, options?: { 27 | timeoutLimit?: number[]; 28 | batchLimit?: number[]; 29 | }): Promise; 30 | setRoute(status: string, count: number, options?: { 31 | timeoutLimit?: number[]; 32 | batchLimit?: number[]; 33 | }): Promise; 34 | } 35 | export default CascadeProducer; 36 | -------------------------------------------------------------------------------- /docs/scripts/search.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | const input = document.querySelector('#search') 3 | const targets = [ ...document.querySelectorAll('#sidebarNav li')] 4 | input.addEventListener('keyup', () => { 5 | // loop over each targets and hide the not corresponding ones 6 | targets.forEach(target => { 7 | if (!target.innerText.toLowerCase().includes(input.value.toLowerCase())) { 8 | target.style.display = 'none' 9 | 10 | /** 11 | * Detects an empty list 12 | * Remove the list and the list's title if the list is not displayed 13 | */ 14 | const list = [...target.parentNode.childNodes].filter( elem => elem.style.display !== 'none') 15 | 16 | if (!list.length) { 17 | target.parentNode.style.display = 'none' 18 | target.parentNode.previousSibling.style.display = 'none' 19 | } 20 | 21 | /** 22 | * Detects empty category 23 | * Remove the entire category if no item is displayed 24 | */ 25 | const category = [...target.parentNode.parentNode.childNodes] 26 | .filter( elem => elem.tagName !== 'H2' && elem.style.display !== 'none') 27 | 28 | if (!category.length) { 29 | target.parentNode.parentNode.style.display = 'none' 30 | } 31 | } else { 32 | target.parentNode.style.display = 'block' 33 | target.parentNode.previousSibling.style.display = 'block' 34 | target.parentNode.parentNode.style.display = 'block' 35 | target.style.display = 'block' 36 | } 37 | }) 38 | }) 39 | })() -------------------------------------------------------------------------------- /demo/client/containers/OptionContainer.scss: -------------------------------------------------------------------------------- 1 | .optionContainer { 2 | display:flex; 3 | flex-direction: column; 4 | align-items: center; 5 | width: 100%; 6 | } 7 | 8 | .buttonsContainer { 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | width:100%; 13 | } 14 | 15 | .messageSliderContainer { 16 | margin-top: 10px; 17 | width:75%; 18 | } 19 | 20 | .radioGroup { 21 | display:flex; 22 | justify-content: center; 23 | } 24 | 25 | .spacer { 26 | width:120px; 27 | } 28 | 29 | .endButton { 30 | margin-left: 10px; 31 | } 32 | 33 | .retryOptionContainer { 34 | display: flex; 35 | flex-direction: row; 36 | align-items: center; 37 | } 38 | 39 | .retryOptionLeftBox { 40 | padding-right: 40px; 41 | } 42 | 43 | .numberOfRetriesContainer { 44 | width: 140px; 45 | } 46 | 47 | .frameContainer{ 48 | height: 15em; 49 | width: 10em; 50 | min-height: 1px; 51 | float: left; 52 | } 53 | 54 | .frame { 55 | margin-left: 100px; 56 | overflow-y: auto; 57 | border: 2px solid purple; 58 | border-radius: 5px; 59 | height: 15em; 60 | width: 10em; 61 | line-height: 1em; 62 | float: right; 63 | } 64 | 65 | .frame::-webkit-scrollbar { 66 | -webkit-appearance: none; 67 | } 68 | 69 | .frame::-webkit-scrollbar:vertical { 70 | width: 11px; 71 | } 72 | 73 | .frame::-webkit-scrollbar:horizontal { 74 | height: 11px; 75 | } 76 | 77 | .frame::-webkit-scrollbar-thumb { 78 | border-radius: 8px; 79 | border: 2px solid white; /* should match background, can't be transparent */ 80 | background-color: rgba(0, 0, 0, .5); 81 | } 82 | 83 | .textField::-webkit-scrollbar{ 84 | -webkit-appearance: none; 85 | margin: 0; 86 | } -------------------------------------------------------------------------------- /__tests__/cascade.metadata.test.ts: -------------------------------------------------------------------------------- 1 | const cascade = require('../kafka-cascade/index'); 2 | import * as Types from '../kafka-cascade/src/kafkaInterface'; 3 | import { TestKafka } from './cascade.mockclient.test'; 4 | 5 | const log = console.log; 6 | console.log = (test, ...args) => test === 'test' && log(args); 7 | // console.log = jest.fn(); 8 | process.env.test = 'test'; 9 | 10 | describe('Cascade Metadata', () => { 11 | let kafka: TestKafka; 12 | let testService: any; 13 | 14 | beforeEach(() => { 15 | kafka = new TestKafka(); 16 | }); 17 | 18 | it('Can parse metadata', async () => { 19 | const fn = cascade.getMetadata; 20 | cascade.getMetadata = jest.fn(fn); 21 | 22 | const serviceAction = (msg: Types.KafkaConsumerMessageInterface, resolve: any, reject: any) => { 23 | const metadata = cascade.getMetadata(msg); 24 | resolve(msg); 25 | } 26 | 27 | const dlq = jest.fn(); 28 | 29 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, jest.fn(), dlq); 30 | await testService.connect(); 31 | await testService.run(); 32 | 33 | const producer = kafka.producer(); 34 | await producer.send({ 35 | topic: 'test-topic', 36 | messages: [{ 37 | value: 'test message', 38 | }], 39 | }); 40 | 41 | expect(cascade.getMetadata).toHaveBeenCalled(); 42 | expect(cascade.getMetadata).toHaveReturnedWith({status:'', retries:0, topicArr:[]}); 43 | }); 44 | 45 | it('Returns undefined on bad data', () => { 46 | const metadata = cascade.getMetadata({topic:'test-topic', offset:-1, partition:-1, 'message':{value:'test'}}); 47 | expect(metadata).toBeUndefined(); 48 | }); 49 | }); -------------------------------------------------------------------------------- /demo/client/components/RadioButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | Radio, 4 | RadioGroup, 5 | FormControlLabel, 6 | FormControl, 7 | FormLabel, 8 | Typography, 9 | } from "@material-ui/core"; 10 | 11 | interface RadioButtonGroupProps { 12 | 13 | } 14 | 15 | interface RadioButtonGroupState { 16 | retryType: { 17 | fastRetry: boolean, 18 | timeout: boolean, 19 | batching: boolean, 20 | }, 21 | } 22 | 23 | export const RadioButtonGroup: FC = (props:any) => { 24 | 25 | const {retryType, setRetryType, handleChange} = props; 26 | 27 | let buttonValue = ''; 28 | 29 | for (const key in retryType) { 30 | if (retryType[key]) buttonValue = key; 31 | } 32 | 33 | return( 34 |
35 | 36 | 37 | 38 | Retry Strategy 39 | 40 | 46 | } 49 | label="Fast Retry" /> 50 | } 53 | label="Timeout" 54 | /> 55 | } 58 | label="Batching" 59 | /> 60 | 61 | 62 |
63 | ); 64 | 65 | } -------------------------------------------------------------------------------- /demo/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | zookeeper: 4 | image: confluentinc/cp-zookeeper:latest 5 | environment: 6 | ZOOKEEPER_CLIENT_PORT: 2181 7 | ZOOKEEPER_TICK_TIME: 2000 8 | 9 | kafka: 10 | # "`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,- 11 | # An important note about accessing Kafka from clients on other machines: 12 | # ----------------------------------------------------------------------- 13 | # 14 | # The config used here exposes port 9092 for _external_ connections to the broker 15 | # i.e. those from _outside_ the docker network. This could be from the host machine 16 | # running docker, or maybe further afield if you've got a more complicated setup. 17 | # If the latter is true, you will need to change the value 'localhost' in 18 | # KAFKA_ADVERTISED_LISTENERS to one that is resolvable to the docker host from those 19 | # remote clients 20 | # 21 | # For connections _internal_ to the docker network, such as from other services 22 | # and components, use kafka:29092. 23 | # 24 | # See https://rmoff.net/2018/08/02/kafka-listeners-explained/ for details 25 | # "`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,- 26 | # 27 | image: confluentinc/cp-kafka:latest 28 | depends_on: 29 | - zookeeper 30 | ports: 31 | - 9092:9092 32 | environment: 33 | KAFKA_BROKER_ID: 1 34 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 35 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 36 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 37 | KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT 38 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 -------------------------------------------------------------------------------- /docs/scripts/app.min.js: -------------------------------------------------------------------------------- 1 | "use strict";$().ready(function(){});var sidebarIsVisible=!1,toggleSidebar=function(e){var a=!(0 h1").text();if(t){o.append($("

").text(t));var s=$("
    ");i.find(".members h4.name").each(function(e,a){var i=$(a),t=i.find(".code-name").clone().children().remove().end().text(),n=i.find("a").attr("href"),r=$('')).text(t);s.append($("
  • ").append(r)),c.push({link:r,offset:i.offset().top})}),o.append(s)}else i.find(".members h4.name").each(function(e,a){var i=$(a),t=i.find(".code-name").clone().children().remove().end().text(),n=i.find("a").attr("href"),r=$('
    ')).text(t);o.append(r),c.push({link:r,offset:i.offset().top})})}),!$.trim(o.text()))return o.hide();function e(){for(var e=n.scrollTop(),a=!1,i=c.length-1;0<=i;i--){var t=c[i];t.link.removeClass("is-active"),e+OFFSET>=t.offset?a?t.link.addClass("is-past"):(t.link.addClass("is-active"),a=!0):t.link.removeClass("is-past")}}var n=$("#main-content-wrapper");n.on("scroll",e),e(),c.forEach(function(e){e.link.click(function(){n.animate({scrollTop:e.offset-OFFSET+1},500)})})}),$().ready(function(){$("#sidebarNav a").each(function(e,a){var i=$(a).attr("href");window.location.pathname.match("/"+i)&&($(a).addClass("active"),$("#sidebarNav").scrollTop($(a).offset().top-150))})}); -------------------------------------------------------------------------------- /demo/client/components/Radiogroup.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { 3 | Radio, 4 | RadioGroup, 5 | FormControlLabel, 6 | FormControl, 7 | FormLabel, 8 | } from "@material-ui/core"; 9 | 10 | interface RadioButtonGroupProps { 11 | 12 | } 13 | 14 | interface RadioButtonGroupState { 15 | retryType: { 16 | fastRetry: boolean, 17 | timeout: boolean, 18 | batching: boolean, 19 | }, 20 | } 21 | 22 | export const RadioButtonGroup: FC = (props:any) => { 23 | const [retryType, setRetryType] = React.useState({ 24 | fastRetry: true, 25 | timeout: false, 26 | batching: false, 27 | }); 28 | 29 | const handleChange = (event: React.ChangeEvent) => { 30 | 31 | // setRetryType((event.target as HTMLInputElement).value); 32 | let newRetryType = {...retryType}; 33 | newRetryType = { 34 | fastRetry: false, 35 | timeout: false, 36 | batching: false, 37 | } 38 | newRetryType[(event.target as HTMLInputElement).value] = true; 39 | setRetryType(newRetryType); 40 | }; 41 | 42 | return( 43 | 44 | Retry Strategy 45 | 51 | } 54 | label="Fast Retry" /> 55 | } 58 | label="Timeout" 59 | /> 60 | } 63 | label="Batching" 64 | /> 65 | 66 | 67 | ); 68 | 69 | } -------------------------------------------------------------------------------- /demo/server/server.ts: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | import { heartbeat } from './controllers/cascadeController'; 4 | const path = require ('path'); 5 | const favicon = require('serve-favicon'); 6 | const https = require('https'); 7 | const fs = require('fs'); 8 | 9 | const PORT = process.env.APP_PORT; 10 | const app = express(); 11 | 12 | app.use(express.json()); 13 | 14 | app.get('/', (req, res)=>{ 15 | res.status(200).sendFile(path.join(__dirname, '../index.html')); 16 | }); 17 | 18 | app.get('/dist/bundle.js', (req, res)=>{ 19 | res.status(200).sendFile(path.join(__dirname, '../dist/bundle.js')); 20 | }); 21 | 22 | app.use(express.static('assets')); 23 | app.use('/doc', express.static(path.join(__dirname, '../../docs'))); 24 | app.use(favicon(path.resolve(__dirname, '../assets/favicon.ico'))); 25 | 26 | // 404 handler 27 | app.use('*', (req, res) => { 28 | res.status(404).send('Cannot find ' + req.baseUrl); 29 | }); 30 | 31 | // global error handler 32 | app.use((err, req, res, next) => { 33 | console.log('Error: ' + (err.log || 'unknown error occured')); 34 | res.status(err.status || 500).send(err.message || 'unknown error'); 35 | }); 36 | 37 | // start server 38 | https.createServer({ 39 | key: fs.readFileSync(process.env.SERVER_KEY), 40 | cert: fs.readFileSync(process.env.SERVER_CERT), 41 | }, app).listen(PORT, () => { 42 | console.log(`Listening to PORT ${PORT}...`); 43 | }); 44 | 45 | 46 | const httpApp = express(); 47 | const HTTP_PORT = process.env.HTTP_PORT; 48 | httpApp.get('/', (req, res) => { 49 | res.set('Content-Type', 'text/html'); 50 | res.status(301).send(Buffer.from('')); 51 | }); 52 | 53 | httpApp.listen(HTTP_PORT, () => { 54 | console.log(`http redirect listening on port ${HTTP_PORT}...`); 55 | }); 56 | 57 | heartbeat(); -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kafka-cascade-demo", 3 | "version": "0.0.1", 4 | "description": "DLQ handling for KafkaJS_DEMO", 5 | "main": "index.js", 6 | "scripts": { 7 | "demo": "npm start & npm run kafka", 8 | "start": "NODE_ENV=production; webpack & ts-node server/server.ts", 9 | "dev": "NODE_ENV=production; webpack-dev-server --open & tsc --watch --project . --outDir ./dist --onSuccess \"nodemon ./dist/server.js\"", 10 | "kafka": "docker-compose up" 11 | }, 12 | "keywords": [ 13 | "Kafka", 14 | "Message Reprocessing", 15 | "Dead Letter Queue" 16 | ], 17 | "author": "Davette Byran, Michael Weber, Robert Du, Seung Joon Lee", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/oslabs-beta/Kafka-Cascade" 22 | }, 23 | "dependencies": { 24 | "@material-ui/core": "^4.12.1", 25 | "@material-ui/icons": "^4.11.2", 26 | "@material-ui/system": "^4.12.1", 27 | "@types/react": "^17.0.11", 28 | "@types/react-dom": "^17.0.8", 29 | "chart.js": "^3.4.1", 30 | "d3-scale-chromatic": "^3.0.0", 31 | "dotenv": "^8.6.0", 32 | "dotenv-webpack": "^7.0.3", 33 | "kafkajs": "^1.15.0", 34 | "react": "^17.0.2", 35 | "react-dom": "^17.0.2", 36 | "react-router": "^5.2.0", 37 | "react-scroll": "^1.8.2", 38 | "serve-favicon": "^2.5.0", 39 | "typescript": "^4.3.4", 40 | "webpack-cli": "^4.7.2" 41 | }, 42 | "devDependencies": { 43 | "@types/jest": "^26.0.24", 44 | "css-loader": "^5.2.6", 45 | "express": "^4.17.1", 46 | "html-webpack-plugin": "^5.3.2", 47 | "jest": "^27.0.6", 48 | "nodejs-websocket": "^1.7.2", 49 | "nodemon": "^2.0.7", 50 | "sass": "^1.35.1", 51 | "sass-loader": "^12.1.0", 52 | "style-loader": "^3.0.0", 53 | "ts-loader": "^9.2.3", 54 | "ts-node": "^10.0.0", 55 | "webpack": "^5.41.0", 56 | "webpack-cli": "^4.7.2", 57 | "webpack-dev-server": "^3.11.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /demo/client/socket.ts: -------------------------------------------------------------------------------- 1 | const wsURL = 'wss://' + process.env.DOMAIN + ':' + process.env.WEBSOCKET_PORT + '/'; 2 | 3 | class Socket { 4 | socket:WebSocket; 5 | connected:boolean; 6 | nextId:number; 7 | listeners: {[details: string] : { id:number, callback:(type:any, payload:any, id?:number) => void}[]}; 8 | connect: () => Promise; 9 | 10 | constructor() { 11 | this.connect = () => { 12 | this.socket = new WebSocket(wsURL); 13 | 14 | this.socket.onmessage = (e) => { 15 | const msg = JSON.parse(e.data); 16 | if(this.listeners[msg.type]) this.listeners[msg.type].forEach(l => l.callback(msg.payload, l.id)); 17 | this.listeners.any.forEach(l => l.callback(msg.type, msg.payload, l.id)); 18 | } 19 | 20 | return new Promise((resolve, reject) => { 21 | this.socket.onopen = (e) => { 22 | this.connected = true; 23 | resolve(true); 24 | }; 25 | }); 26 | } 27 | 28 | this.listeners = {any:[]}; 29 | this.nextId = 0; 30 | this.connected = false; 31 | } 32 | 33 | addListener(type:string, callback: (type:any, payload:any, id?:number) => void) { 34 | if(typeof(type) === 'string') { 35 | if(!this.listeners[type]) this.listeners[type] = []; 36 | this.listeners[type].push({ id:this.nextId, callback}); 37 | } 38 | else this.listeners.any.push({ id:this.nextId, callback:type }); 39 | 40 | return this.nextId++; 41 | } 42 | 43 | removeListener(id: number) { 44 | for(let type in this.listeners) { 45 | let index = -1; 46 | for(let i = 0; i < this.listeners[type].length; i++) { 47 | if(this.listeners[type][i].id === id) { 48 | index = i; 49 | break; 50 | } 51 | } 52 | 53 | if(index > -1) { 54 | this.listeners[type] = this.listeners[type].filter(e => e.id !== id); 55 | break; 56 | } 57 | } 58 | } 59 | 60 | sendEvent(type: string, payload:any = {}) { 61 | this.socket.send(JSON.stringify({type, payload})); 62 | } 63 | } 64 | 65 | const socket = new Socket(); 66 | 67 | export default socket; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | networks: 4 | app-tier: 5 | driver: bridge 6 | 7 | services: 8 | zookeeper: 9 | image: confluentinc/cp-zookeeper:latest 10 | environment: 11 | ZOOKEEPER_CLIENT_PORT: 2181 12 | ZOOKEEPER_TICK_TIME: 2000 13 | networks: 14 | - app-tier 15 | 16 | kafka: 17 | # "`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,- 18 | # An important note about accessing Kafka from clients on other machines: 19 | # ----------------------------------------------------------------------- 20 | # 21 | # The config used here exposes port 9092 for _external_ connections to the broker 22 | # i.e. those from _outside_ the docker network. This could be from the host machine 23 | # running docker, or maybe further afield if you've got a more complicated setup. 24 | # If the latter is true, you will need to change the value 'localhost' in 25 | # KAFKA_ADVERTISED_LISTENERS to one that is resolvable to the docker host from those 26 | # remote clients 27 | # 28 | # For connections _internal_ to the docker network, such as from other services 29 | # and components, use kafka:29092. 30 | # 31 | # See https://rmoff.net/2018/08/02/kafka-listeners-explained/ for details 32 | # "`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,-'"`-._,- 33 | # 34 | image: confluentinc/cp-kafka:latest 35 | depends_on: 36 | - zookeeper 37 | ports: 38 | - 9092:9092 39 | - 29092:29092 40 | environment: 41 | KAFKA_BROKER_ID: 1 42 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 43 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 44 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 45 | KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT 46 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 47 | networks: 48 | - app-tier 49 | 50 | cascade: 51 | image: kafkacascade/demo:latest 52 | depends_on: 53 | - kafka 54 | ports: 55 | - 80:80 56 | - 81:81 57 | networks: 58 | - app-tier 59 | -------------------------------------------------------------------------------- /docs/styles/prettify-jsdoc.css: -------------------------------------------------------------------------------- 1 | /* JSDoc prettify.js theme */ 2 | 3 | /* plain text */ 4 | .pln { 5 | color: #000000; 6 | font-weight: normal; 7 | font-style: normal; 8 | } 9 | 10 | /* string content */ 11 | .str { 12 | color: #006400; 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | 17 | /* a keyword */ 18 | .kwd { 19 | color: #000000; 20 | font-weight: bold; 21 | font-style: normal; 22 | } 23 | 24 | /* a comment */ 25 | .com { 26 | font-weight: normal; 27 | font-style: italic; 28 | } 29 | 30 | /* a type name */ 31 | .typ { 32 | color: #000000; 33 | font-weight: normal; 34 | font-style: normal; 35 | } 36 | 37 | /* a literal value */ 38 | .lit { 39 | color: #006400; 40 | font-weight: normal; 41 | font-style: normal; 42 | } 43 | 44 | /* punctuation */ 45 | .pun { 46 | color: #000000; 47 | font-weight: bold; 48 | font-style: normal; 49 | } 50 | 51 | /* lisp open bracket */ 52 | .opn { 53 | color: #000000; 54 | font-weight: bold; 55 | font-style: normal; 56 | } 57 | 58 | /* lisp close bracket */ 59 | .clo { 60 | color: #000000; 61 | font-weight: bold; 62 | font-style: normal; 63 | } 64 | 65 | /* a markup tag name */ 66 | .tag { 67 | color: #006400; 68 | font-weight: normal; 69 | font-style: normal; 70 | } 71 | 72 | /* a markup attribute name */ 73 | .atn { 74 | color: #006400; 75 | font-weight: normal; 76 | font-style: normal; 77 | } 78 | 79 | /* a markup attribute value */ 80 | .atv { 81 | color: #006400; 82 | font-weight: normal; 83 | font-style: normal; 84 | } 85 | 86 | /* a declaration */ 87 | .dec { 88 | color: #000000; 89 | font-weight: bold; 90 | font-style: normal; 91 | } 92 | 93 | /* a variable name */ 94 | .var { 95 | color: #000000; 96 | font-weight: normal; 97 | font-style: normal; 98 | } 99 | 100 | /* a function name */ 101 | .fun { 102 | color: #000000; 103 | font-weight: bold; 104 | font-style: normal; 105 | } 106 | 107 | /* Specify class=linenums on a pre to get line numbering */ 108 | ol.linenums { 109 | margin-top: 0; 110 | margin-bottom: 0; 111 | } 112 | -------------------------------------------------------------------------------- /kafka-cascade/index.ts: -------------------------------------------------------------------------------- 1 | import CascadeService from './src/cascadeService'; 2 | import CascadeProducer from './src/cascadeProducer'; 3 | import CascadeConsumer from './src/cascadeConsumer' 4 | import * as Types from './src/kafkaInterface'; 5 | 6 | /** 7 | * @module cascade 8 | */ 9 | module.exports = { 10 | /** 11 | * Main entry point to the module. Creates a new service that listens to and produces kafka messages 12 | * 13 | * @example 14 | * const cascade = require('kafka-cascade'); 15 | * const service = new cascade.service(kafka, 'example-topic', 'example-group', serviceCB, successCB, dlqCB); 16 | * 17 | * @param kafka - KakfaJS Kafka Object 18 | * @param {string} topic - Topic that the service listens for and runs the service 19 | * @param {string} groupId - Group Id for the service consumer 20 | * @param serviceCB - Callback that is run whenever 'topic' is received or retry. It accepts a kafka message, resolve callback and reject callback 21 | * @param successCB - Callback that is run when the serviceCB resolves a message, accepts the kafka message 22 | * @param dlqCB - Callback that is run when the serviceCB rejects a message and cannot be retried anymore 23 | * @returns {CascadeService} 24 | */ 25 | service: (kafka: Types.KafkaInterface, topic: string, groupId: string, 26 | serviceCB: Types.ServiceCallback, successCB: (...args: any[]) => any, 27 | dlqCB: Types.RouteCallback = (msg: Types.KafkaConsumerMessageInterface) => console.log('DLQ Message received')): Promise => { 28 | 29 | return new Promise(async (resolve, reject) => { 30 | try { 31 | const newServ = new CascadeService(kafka, topic, groupId, serviceCB, successCB, dlqCB); 32 | resolve(newServ); 33 | } 34 | catch(error) { 35 | reject(error); 36 | } 37 | }); 38 | }, 39 | 40 | /** 41 | * Utility function that parses the metadata that cascade adds to the kafka message headers 42 | * @param msg 43 | * @returns {object} 44 | */ 45 | getMetadata: (msg: Types.KafkaConsumerMessageInterface):{ retires:number, status:string, topicArr:string[] } => { 46 | if(typeof(msg) !== 'object' || !msg.message || !msg.message.headers || !msg.message.headers.cascadeMetadata) return; 47 | return JSON.parse(msg.message.headers.cascadeMetadata); 48 | }, 49 | }; 50 | 51 | export { 52 | CascadeService, 53 | CascadeProducer, 54 | CascadeConsumer, 55 | Types, 56 | }; 57 | -------------------------------------------------------------------------------- /kafka-cascade/src/kafkaInterface.ts: -------------------------------------------------------------------------------- 1 | interface ProducerInterface { 2 | connect: () => Promise; 3 | disconnect: () => any; 4 | send:(arg: KafkaProducerMessageInterface) => any; 5 | } 6 | 7 | interface ConsumerInterface { 8 | connect: () => Promise; 9 | disconnect: () => any; 10 | subscribe: (arg: {topic:string|RegExp, fromBeginning: boolean}) => Promise; 11 | run: (arg: ({ 12 | eachMessage: (msg: KafkaConsumerMessageInterface) => void, 13 | })) => any; 14 | stop: () => Promise; 15 | pause: () => Promise; 16 | resume: () => Promise; 17 | } 18 | 19 | interface AdminInterface { 20 | connect: () => Promise; 21 | disconnect: () => Promise; 22 | listTopics: () => Promise; 23 | createTopics: (arg: {validateOnly?:boolean, waitForLeaders?:boolean, timeout?:number, topics:{topic:string, numPartitions?:number}[]}) => Promise; 24 | deleteTopics: (arg: {topics: string[]}) => any; 25 | } 26 | 27 | interface KafkaInterface { 28 | producer: () => ProducerInterface; 29 | consumer: ({groupId:string}) => ConsumerInterface; 30 | admin: () => AdminInterface; 31 | } 32 | //timeout: number, //used for added delay per retry 33 | interface KafkaProducerMessageInterface { 34 | topic: string, 35 | offset?: number, 36 | partition?:number, 37 | messages: { 38 | key?: string, 39 | value: string, 40 | headers?: { 41 | cascadeMetadata?: string, 42 | } 43 | }[] 44 | } 45 | 46 | interface KafkaConsumerMessageInterface { 47 | topic: string, 48 | partition: number, 49 | offset: number, 50 | message: { 51 | key?: string, 52 | value: string, 53 | headers?: { 54 | cascadeMetadata?: string, 55 | } 56 | } 57 | } 58 | 59 | interface ProducerRoute { 60 | status: string, 61 | retryLevels: number, 62 | timeoutLimit: number[], 63 | batchLimit: number[], 64 | levels: KafkaProducerMessageInterface[], 65 | topics: string[], 66 | } 67 | 68 | interface CascadeMetadata { 69 | status: string, 70 | retries: number, 71 | topicArr: string[], 72 | } 73 | 74 | type ServiceCallback = (msg: KafkaConsumerMessageInterface, resolve: RouteCallback, reject: RouteCallback) => void; 75 | 76 | type RouteCallback = (msg: KafkaConsumerMessageInterface, status?:string) => void; 77 | 78 | export { 79 | ProducerInterface, 80 | ConsumerInterface, 81 | AdminInterface, 82 | KafkaInterface, 83 | KafkaProducerMessageInterface, 84 | KafkaConsumerMessageInterface, 85 | ProducerRoute, 86 | CascadeMetadata, 87 | ServiceCallback, 88 | RouteCallback, 89 | }; -------------------------------------------------------------------------------- /demo/client/App.scss: -------------------------------------------------------------------------------- 1 | .app { 2 | width:1000px; 3 | display:flex; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | 8 | #root { 9 | display:flex; 10 | flex-direction: column; 11 | align-items: center; 12 | width: 100%; 13 | } 14 | 15 | .container { 16 | height: 100vh; 17 | width: 100vw; 18 | font-family: Helvetica; 19 | } 20 | 21 | .loader { 22 | height: 80px; 23 | width: 605px; 24 | position: absolute; 25 | top: 0; 26 | bottom: 30px; 27 | left: 0; 28 | right: 0; 29 | margin: auto; 30 | } 31 | .loader--dot { 32 | animation-name: loader; 33 | animation-timing-function: ease-in-out; 34 | animation-duration: 3s; 35 | animation-iteration-count: infinite; 36 | height: 80px; 37 | width: 80px; 38 | border-radius: 100%; 39 | background-color: black; 40 | position: absolute; 41 | border: 2px solid #efde46; 42 | padding: 10px; 43 | } 44 | .loader--dot:first-child { 45 | background: linear-gradient(133deg, #340361 0%, #010005 100%); 46 | animation-delay: 0.5s; 47 | } 48 | 49 | .loader--dot:nth-child(2) { 50 | background: linear-gradient(133deg, #79176E 0%, #340361 100%); 51 | animation-delay: 0.4s; 52 | } 53 | 54 | .loader--dot:nth-child(3) { 55 | background: linear-gradient(133deg, #BC3454 0%, #79176E 100%); 56 | animation-delay: 0.3s; 57 | } 58 | 59 | .loader--dot:nth-child(4) { 60 | background: linear-gradient(133deg, #F06A0D 0%, #BC3454 100%); 61 | animation-delay: 0.2s; 62 | } 63 | 64 | .loader--dot:nth-child(5) { 65 | background: linear-gradient(133deg, #FDBB00 0%, #F06A0D 100%); 66 | animation-delay: 0.1s; 67 | } 68 | 69 | .loader--dot:nth-child(6) { 70 | background: linear-gradient(133deg, #FAFE9C 0%, #FDBB00 100%); 71 | animation-delay: 0s; 72 | } 73 | 74 | .loader--text { 75 | position: absolute; 76 | top: 200%; 77 | left: 0; 78 | right: 0; 79 | margin: auto; 80 | font-size: 2rem; 81 | text-align: center; 82 | } 83 | .loader--text:after { 84 | content: "Loading"; 85 | font-weight: bold; 86 | animation-name: loading-text; 87 | animation-duration: 3s; 88 | animation-iteration-count: infinite; 89 | } 90 | 91 | @keyframes loader { 92 | 15% { 93 | transform: translateX(0); 94 | } 95 | 45% { 96 | transform: translateX(500px); 97 | } 98 | 65% { 99 | transform: translateX(500px); 100 | } 101 | 95% { 102 | transform: translateX(0); 103 | } 104 | } 105 | @keyframes loading-text { 106 | 0% { 107 | content: "Loading"; 108 | } 109 | 25% { 110 | content: "Loading."; 111 | } 112 | 50% { 113 | content: "Loading.."; 114 | } 115 | 75% { 116 | content: "Loading..."; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /dist/src/kafkaInterface.d.ts: -------------------------------------------------------------------------------- 1 | interface ProducerInterface { 2 | connect: () => Promise; 3 | disconnect: () => any; 4 | send: (arg: KafkaProducerMessageInterface) => any; 5 | } 6 | interface ConsumerInterface { 7 | connect: () => Promise; 8 | disconnect: () => any; 9 | subscribe: (arg: { 10 | topic: string | RegExp; 11 | fromBeginning: boolean; 12 | }) => Promise; 13 | run: (arg: ({ 14 | eachMessage: (msg: KafkaConsumerMessageInterface) => void; 15 | })) => any; 16 | stop: () => Promise; 17 | pause: () => Promise; 18 | resume: () => Promise; 19 | } 20 | interface AdminInterface { 21 | connect: () => Promise; 22 | disconnect: () => Promise; 23 | listTopics: () => Promise; 24 | createTopics: (arg: { 25 | validateOnly?: boolean; 26 | waitForLeaders?: boolean; 27 | timeout?: number; 28 | topics: { 29 | topic: string; 30 | numPartitions?: number; 31 | }[]; 32 | }) => Promise; 33 | deleteTopics: (arg: { 34 | topics: string[]; 35 | }) => any; 36 | } 37 | interface KafkaInterface { 38 | producer: () => ProducerInterface; 39 | consumer: ({ groupId: string }: { 40 | groupId: any; 41 | }) => ConsumerInterface; 42 | admin: () => AdminInterface; 43 | } 44 | interface KafkaProducerMessageInterface { 45 | topic: string; 46 | offset?: number; 47 | partition?: number; 48 | messages: { 49 | key?: string; 50 | value: string; 51 | headers?: { 52 | cascadeMetadata?: string; 53 | }; 54 | }[]; 55 | } 56 | interface KafkaConsumerMessageInterface { 57 | topic: string; 58 | partition: number; 59 | offset: number; 60 | message: { 61 | key?: string; 62 | value: string; 63 | headers?: { 64 | cascadeMetadata?: string; 65 | }; 66 | }; 67 | } 68 | interface ProducerRoute { 69 | status: string; 70 | retryLevels: number; 71 | timeoutLimit: number[]; 72 | batchLimit: number[]; 73 | levels: KafkaProducerMessageInterface[]; 74 | topics: string[]; 75 | } 76 | interface CascadeMetadata { 77 | status: string; 78 | retries: number; 79 | topicArr: string[]; 80 | } 81 | declare type ServiceCallback = (msg: KafkaConsumerMessageInterface, resolve: RouteCallback, reject: RouteCallback) => void; 82 | declare type RouteCallback = (msg: KafkaConsumerMessageInterface, status?: string) => void; 83 | export { ProducerInterface, ConsumerInterface, AdminInterface, KafkaInterface, KafkaProducerMessageInterface, KafkaConsumerMessageInterface, ProducerRoute, CascadeMetadata, ServiceCallback, RouteCallback, }; 84 | -------------------------------------------------------------------------------- /docs/styles/prettify-tomorrow.css: -------------------------------------------------------------------------------- 1 | /* Tomorrow Theme */ 2 | /* Original theme - https://github.com/chriskempson/tomorrow-theme */ 3 | /* Pretty printing styles. Used with prettify.js. */ 4 | /* SPAN elements with the classes below are added by prettyprint. */ 5 | /* plain text */ 6 | .pln { 7 | color: #4d4d4c; } 8 | 9 | @media screen { 10 | /* string content */ 11 | .str { 12 | color: #718c00; } 13 | 14 | /* a keyword */ 15 | .kwd { 16 | color: #8959a8; } 17 | 18 | /* a comment */ 19 | .com { 20 | color: #8e908c; } 21 | 22 | /* a type name */ 23 | .typ { 24 | color: #4271ae; } 25 | 26 | /* a literal value */ 27 | .lit { 28 | color: #f5871f; } 29 | 30 | /* punctuation */ 31 | .pun { 32 | color: #4d4d4c; } 33 | 34 | /* lisp open bracket */ 35 | .opn { 36 | color: #4d4d4c; } 37 | 38 | /* lisp close bracket */ 39 | .clo { 40 | color: #4d4d4c; } 41 | 42 | /* a markup tag name */ 43 | .tag { 44 | color: #c82829; } 45 | 46 | /* a markup attribute name */ 47 | .atn { 48 | color: #f5871f; } 49 | 50 | /* a markup attribute value */ 51 | .atv { 52 | color: #3e999f; } 53 | 54 | /* a declaration */ 55 | .dec { 56 | color: #f5871f; } 57 | 58 | /* a variable name */ 59 | .var { 60 | color: #c82829; } 61 | 62 | /* a function name */ 63 | .fun { 64 | color: #4271ae; } } 65 | /* Use higher contrast and text-weight for printable form. */ 66 | @media print, projection { 67 | .str { 68 | color: #060; } 69 | 70 | .kwd { 71 | color: #006; 72 | font-weight: bold; } 73 | 74 | .com { 75 | color: #600; 76 | font-style: italic; } 77 | 78 | .typ { 79 | color: #404; 80 | font-weight: bold; } 81 | 82 | .lit { 83 | color: #044; } 84 | 85 | .pun, .opn, .clo { 86 | color: #440; } 87 | 88 | .tag { 89 | color: #006; 90 | font-weight: bold; } 91 | 92 | .atn { 93 | color: #404; } 94 | 95 | .atv { 96 | color: #060; } } 97 | /* Style */ 98 | /* 99 | pre.prettyprint { 100 | background: white; 101 | font-family: Consolas, Monaco, 'Andale Mono', monospace; 102 | font-size: 12px; 103 | line-height: 1.5; 104 | border: 1px solid #ccc; 105 | padding: 10px; } 106 | */ 107 | 108 | /* Specify class=linenums on a pre to get line numbering */ 109 | ol.linenums { 110 | margin-top: 0; 111 | margin-bottom: 0; } 112 | 113 | /* IE indents via margin-left */ 114 | li.L0, 115 | li.L1, 116 | li.L2, 117 | li.L3, 118 | li.L4, 119 | li.L5, 120 | li.L6, 121 | li.L7, 122 | li.L8, 123 | li.L9 { 124 | /* */ } 125 | 126 | /* Alternate shading for lines */ 127 | li.L1, 128 | li.L3, 129 | li.L5, 130 | li.L7, 131 | li.L9 { 132 | /* */ } 133 | -------------------------------------------------------------------------------- /demo/client/components/GettingStarted.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | Container, Typography, makeStyles, 4 | createStyles, Button, Card, CardContent, 5 | } from '@material-ui/core'; 6 | 7 | const GettingStarted: FC = () => { 8 | 9 | const useStyles = makeStyles(() => createStyles({ 10 | button: { 11 | margin: '1rem 1rem 1rem 1rem', 12 | }, 13 | buttonsContainer: { 14 | display: 'flex', 15 | justifyContent: 'center', 16 | }, 17 | wrapper: { 18 | display: 'flex', 19 | flexDirection: 'column', 20 | justifyItems: 'center', 21 | alignItems: 'center', 22 | }, 23 | card: { 24 | display: 'flex', 25 | flexDirection: 'column', 26 | alignItems: 'center', 27 | alignContent: 'center', 28 | justifyContent: 'space-between', 29 | marginTop: '3vh', 30 | backgroundColor: 'rgba(225, 218, 232)', 31 | paddingTop: '2vh', 32 | paddingBottom: '2vh', 33 | width: '80%', 34 | }, 35 | root: { 36 | backgroundColor: 'rgba(211,211,211)', 37 | margin: '5px', 38 | minWidth: 'md', 39 | }, 40 | })) 41 | 42 | const classes = useStyles(); 43 | 44 | return ( 45 | 46 | 52 | Getting Started 53 | 54 | 55 | 56 | Install the Kafka-Cascade library from npm to add message retry 57 | handling to your KafkaJS project 58 | 59 |
    60 | 61 | 62 | {'npm install kafka-cascade'} 63 | 64 | 65 | 66 | 75 | 85 | 86 |
    87 |
    88 | ); 89 | 90 | } 91 | 92 | export default GettingStarted; -------------------------------------------------------------------------------- /kafka-cascade/src/cascadeConsumer.ts: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | import * as Types from './kafkaInterface'; 3 | 4 | 5 | class CascadeConsumer extends EventEmitter{ 6 | consumer: Types.ConsumerInterface; 7 | topic: string; 8 | groupId: string; 9 | fromBeginning: boolean; 10 | 11 | constructor(kafkaInterface: Types.KafkaInterface, topic: string, groupId: string, fromBeginning: boolean = false){ 12 | super(); 13 | // kafka interface to this 14 | this.consumer = kafkaInterface.consumer({groupId}); 15 | this.topic = topic; 16 | this.groupId = groupId; 17 | this.fromBeginning = fromBeginning; 18 | } 19 | 20 | // Connect and subscribe to both reg and regex for the topic 21 | connect(): Promise { 22 | return new Promise(async (resolve, reject) => { 23 | try { 24 | await this.consumer.connect(); 25 | console.log('Connected to the consumer...'); 26 | await this.consumer.subscribe({topic: this.topic, fromBeginning: this.fromBeginning}); 27 | console.log('Subscribed to the base topic:', this.topic); 28 | let re = new RegExp(`^${this.topic}-cascade-retry-.*`); 29 | await this.consumer.subscribe({topic: re, fromBeginning: this.fromBeginning}); 30 | console.log('Connected to the retry topics...'); 31 | resolve(true); 32 | } 33 | catch(error) { 34 | this.emit('error', error); 35 | reject(error); 36 | } 37 | }); 38 | } 39 | 40 | run(serviceCB: Types.ServiceCallback, successCB: Types.RouteCallback, rejectCB: Types.RouteCallback):Promise { 41 | return this.consumer.run({eachMessage: (msg: Types.KafkaConsumerMessageInterface)=> { 42 | try { 43 | if (!msg.message.headers) { 44 | msg.message.headers = {}; 45 | } 46 | 47 | if (!msg.message.headers.cascadeMetadata) { 48 | msg.message.headers.cascadeMetadata = JSON.stringify({ 49 | status: '', 50 | retries: 0, 51 | topicArr: [], 52 | }) 53 | } 54 | if(msg.topic === this.topic) this.emit('receive', msg); 55 | } 56 | catch(error) { 57 | this.emit('error', error); 58 | } 59 | 60 | try { 61 | serviceCB(msg, successCB, rejectCB); 62 | } 63 | catch(error) { 64 | this.emit('serviceError', error); 65 | } 66 | }}); 67 | } 68 | 69 | disconnect(): Promise { 70 | return this.consumer.disconnect(); 71 | } 72 | 73 | stop(): Promise { 74 | return this.consumer.stop(); 75 | } 76 | 77 | pause(): Promise { 78 | return this.consumer.pause(); 79 | } 80 | 81 | resume(): Promise { 82 | return this.consumer.resume(); 83 | } 84 | 85 | on(event: string, callback: (arg: any) => any) { 86 | super.on(event, callback); 87 | } 88 | } 89 | 90 | export default CascadeConsumer; -------------------------------------------------------------------------------- /demo/server/websocket.ts: -------------------------------------------------------------------------------- 1 | const ws = require("nodejs-websocket"); 2 | const fs = require('fs'); 3 | 4 | const PORT = Number(process.env.WEBSOCKET_PORT); 5 | const routes = []; 6 | 7 | const options = { 8 | key: fs.readFileSync(process.env.SERVER_KEY), 9 | cert: fs.readFileSync(process.env.SERVER_CERT), 10 | 11 | secure:true, 12 | }; 13 | 14 | const socket = { 15 | server: ws 16 | .createServer(options, (conn) => { 17 | conn.on("text", (str) => { 18 | const msg = JSON.parse(str); 19 | 20 | let routeIndex = -1; 21 | for (let i = 0; i < routes.length; i++) { 22 | if (routes[i].start === msg.type) { 23 | routeIndex = i; 24 | break; 25 | } 26 | } 27 | 28 | if (routeIndex === -1) 29 | conn.send( 30 | JSON.stringify({ 31 | type: "error", 32 | payload: { error: "Unknown message type" }, 33 | }) 34 | ); 35 | else 36 | dispatcher( 37 | msg.payload, 38 | { conn, locals: {} }, 39 | ...routes[routeIndex].stops 40 | ); 41 | }); 42 | 43 | conn.on("close", (code, reason) => { 44 | let routeIndex = -1; 45 | for (let i = 0; i < routes.length; i++) { 46 | if (routes[i].start === "close") { 47 | routeIndex = i; 48 | break; 49 | } 50 | } 51 | if (routeIndex !== -1) 52 | dispatcher({}, { conn, locals: {} }, ...routes[routeIndex].stops); 53 | }); 54 | 55 | conn.on("error", (err) => { 56 | console.log(`Errr on connection '${conn.key}': ${err}`); 57 | }); 58 | }) 59 | .listen(PORT), 60 | 61 | use: (start:string, ...stops) => { 62 | routes.push({ start, stops }); 63 | }, 64 | 65 | send: (type:string, payload:any) => { 66 | socket.server.connections.forEach(conn => { 67 | conn.send(JSON.stringify({type, payload: payload})); 68 | }); 69 | } 70 | }; 71 | 72 | socket.server.on("listening", () => { 73 | console.log(`Websocket server listnening on port ${PORT}...`); 74 | }); 75 | 76 | socket.server.on("connection", (conn) => { 77 | console.log("Connection established with ", conn.key); 78 | }); 79 | 80 | socket.server.on("close", () => { 81 | console.log("Websocket server has shut down"); 82 | }); 83 | 84 | socket.server.on("error", (e) => { 85 | console.log("Websocket error caught: ", e); 86 | }); 87 | 88 | const dispatcher = (req, res, ...route) => { 89 | if (route[0]) 90 | route[0](req, res, (error) => { 91 | if (error) { 92 | res.conn.send(JSON.stringify({ type: "error", payload: { error } })); 93 | } else { 94 | dispatcher(req, res, ...route.slice(1)); 95 | } 96 | }); 97 | }; 98 | 99 | export default socket; 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://img.shields.io/npm/v/kafka-cascade?color=%2344cc11&label=stable)](https://www.npmjs.com/package/kafka-cascade) 2 | 3 |

    4 | 5 |

    Kafka Cascade

    6 | 7 |
    8 | 9 | Logo 10 | 11 |
    12 | 13 |

    14 | A lightweight npm library for KafkaJS message reprocessing 15 |
    16 | Demo and Docs » 17 |

    18 |

    19 | 20 | --- 21 | 22 | ## Table of Contents 23 | 24 | - [About](#about) 25 | - [Features](#features) 26 | - [Getting Started](#getting-started) 27 | - [Usage](#usage) 28 | - [Authors](#authors) 29 | 30 | --- 31 | 32 | ## About Kafka Cascade 33 | 34 | Kafka Cascade is a lightweight npm library for [KafkaJS](https://kafka.js.org/), a modern [Apache Kafka](https://kafka.apache.org/) client for Node.js. 35 | 36 | Kafka Cascade wraps around KafkaJS objects to provide a user-configurable service for reprocessing messages, allowing developers to decide how to handle retries and when messages should be sent to the dead letter queue (DLQ). 37 | 38 | KAFKA is a registered trademark of The Apache Software Foundation. Kafka Cascade has no affiliation with and is not endorsed by The Apache Software Foundation. Kafka Cascade was developed separately from KafkaJS and is not affiliated with KafkaJS 39 | 40 | 41 | ### Features 42 | 43 | * Retry Strategies 44 | 1. Fast Retry - a default strategy that will resend messages as quickly as the runtime will allow, up to a user-defined limit 45 | 2. Timeout Retry - specifies how long to wait before resending a message based on retry level 46 | 3. Batching Retry - specifies how many messages the producer should wait for before sending all of the messages to be retried at once 47 | * All retry strategies are user-configurable 48 | 49 | 50 | ### Getting Started 51 | 52 | ```sh 53 | npm install kafka-cascade 54 | # yarn add kafka-cascade 55 | ``` 56 | 57 | 58 | ### Usage 59 | 60 | KafkaJS objects should be wrapped in the Kafka Cascade CascadeService. The user will provide the topic, groupId, and the callbacks to be invoked upon successfully delivery a message or when a message ends up in the DLQ. For more details, please see the [documentation](https://kafka-cascade.io/doc). 61 | 62 | ```javascript 63 | var service: Cascade.CascadeService; 64 | service = await cascade.service(kafka, topic, groupId, serviceCB, successCB, dlqCB) 65 | ``` 66 | --- 67 | 68 | ### Authors 69 | 70 | ##### [Michael Weber](https://github.com/michaelweberjr) 71 | ##### [Davette Bryan](https://github.com/Davette-Bryan) 72 | ##### [Seung Joon Lee](https://github.com/GnuesJ) 73 | ##### [Robert Du](https://github.com/robertcdu) -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | exports.Types = exports.CascadeConsumer = exports.CascadeProducer = exports.CascadeService = void 0; 13 | const cascadeService_1 = require("./src/cascadeService"); 14 | exports.CascadeService = cascadeService_1.default; 15 | const cascadeProducer_1 = require("./src/cascadeProducer"); 16 | exports.CascadeProducer = cascadeProducer_1.default; 17 | const cascadeConsumer_1 = require("./src/cascadeConsumer"); 18 | exports.CascadeConsumer = cascadeConsumer_1.default; 19 | const Types = require("./src/kafkaInterface"); 20 | exports.Types = Types; 21 | /** 22 | * @module cascade 23 | */ 24 | module.exports = { 25 | /** 26 | * Main entry point to the module. Creates a new service that listens to and produces kafka messages 27 | * 28 | * @example 29 | * const cascade = require('kafka-cascade'); 30 | * const service = new cascade.service(kafka, 'example-topic', 'example-group', serviceCB, successCB, dlqCB); 31 | * 32 | * @param kafka - KakfaJS Kafka Object 33 | * @param {string} topic - Topic that the service listens for and runs the service 34 | * @param {string} groupId - Group Id for the service consumer 35 | * @param serviceCB - Callback that is run whenever 'topic' is received or retry. It accepts a kafka message, resolve callback and reject callback 36 | * @param successCB - Callback that is run when the serviceCB resolves a message, accepts the kafka message 37 | * @param dlqCB - Callback that is run when the serviceCB rejects a message and cannot be retried anymore 38 | * @returns {CascadeService} 39 | */ 40 | service: (kafka, topic, groupId, serviceCB, successCB, dlqCB = (msg) => console.log('DLQ Message received')) => { 41 | return new Promise((resolve, reject) => __awaiter(void 0, void 0, void 0, function* () { 42 | try { 43 | const newServ = new cascadeService_1.default(kafka, topic, groupId, serviceCB, successCB, dlqCB); 44 | resolve(newServ); 45 | } 46 | catch (error) { 47 | reject(error); 48 | } 49 | })); 50 | }, 51 | /** 52 | * Utility function that parses the metadata that cascade adds to the kafka message headers 53 | * @param msg 54 | * @returns {object} 55 | */ 56 | getMetadata: (msg) => { 57 | if (typeof (msg) !== 'object' || !msg.message || !msg.message.headers || !msg.message.headers.cascadeMetadata) 58 | return; 59 | return JSON.parse(msg.message.headers.cascadeMetadata); 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /demo/client/components/About.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | makeStyles, createStyles, Card, CardContent, 4 | Typography, Container, 5 | } from '@material-ui/core'; 6 | import GitHubIcon from '@material-ui/icons/GitHub'; 7 | import LinkedInIcon from '@material-ui/icons/LinkedIn'; 8 | 9 | 10 | const About: FC = () => { 11 | const useStyles = makeStyles(() => createStyles({ 12 | wrapper: { 13 | display: 'flex', 14 | flexDirection: 'column', 15 | justifyContent: 'center', 16 | alignItems: 'center', 17 | }, 18 | container: { 19 | display: 'flex', 20 | flexDirection: 'row', 21 | flexWrap: 'wrap', 22 | justifyContent: 'space-evenly', 23 | alignItems: 'center', 24 | }, 25 | root: { 26 | backgroundColor: 'rgba(225, 218, 232)', 27 | padding: '1rem 1rem 1rem 1rem', 28 | margin: '5px', 29 | minWidth: 'md', 30 | }, 31 | })); 32 | 33 | const classes = useStyles(); 34 | 35 | const contributors = [ 36 | { 37 | name: 'Davette Bryan', 38 | github: 'https://github.com/Davette-Bryan', 39 | linkedin: 'https://www.linkedin.com/in/davette-bryan/', 40 | photo: 'Davette_Bryan.jpg', 41 | }, 42 | { 43 | name: 'Robert Du', 44 | github: 'https://github.com/robertcdu', 45 | linkedin: 'https://www.linkedin.com/in/robert--du/', 46 | photo: 'Robert_Du.jpg', 47 | }, 48 | { 49 | name: 'Seung Joon Lee', 50 | github: 'https://github.com/GnuesJ', 51 | linkedin: '', 52 | photo: 'SeungJoonLee.jpg', 53 | }, 54 | { 55 | name: 'Michael Weber', 56 | github: 'https://github.com/michaelweberjr', 57 | linkedin: 'https://www.linkedin.com/in/michael-weber-jr/', 58 | photo: 'Michael_Weber.png', 59 | }, 60 | ]; 61 | 62 | const contributorArray = []; 63 | 64 | // create array of contributor jsx code for each contributor object 65 | contributors.forEach((contributor) => { 66 | contributorArray.push( 67 | 68 | 69 | 70 | photo 79 |
    80 | {contributor.name} 81 |
    82 | 83 | 84 | 85 | 86 | 87 | 88 |
    89 |
    90 |
    91 | ) 92 | }); 93 | 94 | return ( 95 | 96 | 102 | About 103 | 104 | 105 | {contributorArray} 106 | 107 | 108 | ); 109 | }; 110 | 111 | export default About; 112 | -------------------------------------------------------------------------------- /demo/client/components/Features.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | Container, Typography, makeStyles, createStyles, 4 | } from '@material-ui/core'; 5 | import FastForwardIcon from '@material-ui/icons/FastForward'; 6 | import TimerIcon from '@material-ui/icons/Timer'; 7 | import ViewComfyIcon from '@material-ui/icons/ViewComfy'; 8 | 9 | const Features: FC = () => { 10 | const useStyles = makeStyles(() => createStyles ({ 11 | wrapper: { 12 | display: 'flex', 13 | flexDirection: 'column', 14 | justifyItems: 'center', 15 | alignItems: 'center', 16 | }, 17 | container: { 18 | display: 'flex', 19 | flexDirection: 'column', 20 | alignItems: 'center', 21 | alignContent: 'center', 22 | justifyContent: 'space-between', 23 | }, 24 | card: { 25 | display: 'flex', 26 | flexDirection: 'column', 27 | alignItems: 'center', 28 | alignContent: 'center', 29 | justifyContent: 'space-between', 30 | marginTop: '3vh', 31 | backgroundColor: 'rgba(225, 218, 232)', 32 | paddingTop: '2vh', 33 | paddingBottom: '2vh', 34 | width: '80%', 35 | }, 36 | icon: { 37 | fontSize: '2vh', 38 | padding: '2vh', 39 | } 40 | })) 41 | 42 | const classes = useStyles(); 43 | 44 | const descriptionText = 'Kafka-Cascade is a lightweight npm library that adds message reprocessing and DLQ handling to KafkaJS.' 45 | const fastText = 'Fast retry will resend failed messages as quickly as possible until the retry limit is reached.' 46 | const timeText = 'Time delay allows the user to define an array of time delays for every retry level.' 47 | const batchText = 'Batch retry will accumulate messages to a user-defineable limit and then resend all messages as a batch.' 48 | 49 | return ( 50 | 51 | 57 | Features 58 | 59 | 60 | 61 | 62 | {descriptionText} 63 | 64 | 65 | 66 | 67 | Fast Retry Strategy 68 | 69 | 70 | 71 | {fastText} 72 | 73 | 74 | 75 | 76 | Time Delay Strategy 77 | 78 | 79 | 80 | {timeText} 81 | 82 | 83 | 84 | 85 | Batch Retry Strategy 86 | 87 | 88 | 89 | {batchText} 90 | 91 | 92 | 93 | 94 | ) 95 | } 96 | 97 | export default Features; -------------------------------------------------------------------------------- /__tests__/cascade.test.ts: -------------------------------------------------------------------------------- 1 | const cascade = require('../kafka-cascade/index'); 2 | import * as Types from '../kafka-cascade/src/kafkaInterface'; 3 | import { TestKafka } from './cascade.mockclient.test'; 4 | 5 | 6 | console.log = jest.fn(); 7 | process.env.test = 'test'; 8 | 9 | describe('Basic service tests', () => { 10 | let kafka: TestKafka; 11 | let testService: any; 12 | 13 | beforeEach(() => { 14 | kafka = new TestKafka(); 15 | }); 16 | 17 | it('Can create an empty service object', async () => { 18 | testService = await cascade.service(kafka, 'test-topic', 'test-group', jest.fn(), jest.fn(), jest.fn()); 19 | expect(testService.retries).toBe(0); 20 | }); 21 | 22 | it('All messages end up in DLQ when service is always fail', async () => { 23 | const serviceAction = (msg: Types.KafkaConsumerMessageInterface, resolve: any, reject: any) => { 24 | try { 25 | reject(msg); 26 | } 27 | catch(error) { 28 | console.log('Caught error in service CB: ' + error); 29 | } 30 | } 31 | 32 | const dlq = jest.fn(); 33 | 34 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, jest.fn(), dlq); 35 | const retryLevels = 5; 36 | await testService.setDefaultRoute(retryLevels); 37 | await testService.connect(); 38 | await testService.run(); 39 | 40 | const producer = kafka.producer(); 41 | const messageCount = 10; 42 | 43 | for(let i = 0; i < messageCount; i++) { 44 | await producer.send({ 45 | topic: 'test-topic', 46 | messages: [{ 47 | value: 'test message', 48 | }], 49 | }); 50 | } 51 | 52 | expect(producer.offsets['test-topic'].count).toBe(messageCount); 53 | const testServiceOffsets = testService.producer.producer.offsets; 54 | expect(Object.keys(testServiceOffsets)).toHaveLength(retryLevels); 55 | for(let topic in testServiceOffsets) { 56 | expect(testServiceOffsets[topic].count).toBe(messageCount); 57 | } 58 | expect(dlq).toHaveBeenCalledTimes(messageCount); 59 | }); 60 | 61 | it('All messages succeed when retries is 1', async () => { 62 | const serviceAction = (msg: Types.KafkaConsumerMessageInterface, resolve: any, reject: any) => { 63 | const { retries, status } = JSON.parse(msg.message.headers.cascadeMetadata); 64 | if(retries === 1) { 65 | resolve(msg); 66 | } 67 | else reject(msg); 68 | } 69 | 70 | const success = jest.fn(); 71 | const dlq = jest.fn(); 72 | 73 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, success, dlq); 74 | const retryLevels = 5; 75 | await testService.setDefaultRoute(retryLevels); 76 | await testService.connect(); 77 | await testService.run(); 78 | 79 | const producer = kafka.producer(); 80 | const messageCount = 10; 81 | for(let i = 0; i < messageCount; i++) { 82 | await producer.send({ 83 | topic: 'test-topic', 84 | messages: [{ 85 | value: 'test message', 86 | }], 87 | }); 88 | } 89 | 90 | expect(producer.offsets['test-topic'].count).toBe(messageCount); 91 | const testServerOffsets = testService.producer.producer.offsets; 92 | expect(Object.keys(testServerOffsets)).toHaveLength(1); 93 | expect(testServerOffsets['test-topic-cascade-retry-1'].count).toBe(messageCount); 94 | expect(success).toHaveBeenCalledTimes(messageCount); 95 | expect(dlq).toHaveBeenCalledTimes(0); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /dist/src/cascadeConsumer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | const EventEmitter = require('events'); 13 | class CascadeConsumer extends EventEmitter { 14 | constructor(kafkaInterface, topic, groupId, fromBeginning = false) { 15 | super(); 16 | // kafka interface to this 17 | this.consumer = kafkaInterface.consumer({ groupId }); 18 | this.topic = topic; 19 | this.groupId = groupId; 20 | this.fromBeginning = fromBeginning; 21 | } 22 | // Connect and subscribe to both reg and regex for the topic 23 | connect() { 24 | return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { 25 | try { 26 | yield this.consumer.connect(); 27 | console.log('Connected to the consumer...'); 28 | yield this.consumer.subscribe({ topic: this.topic, fromBeginning: this.fromBeginning }); 29 | console.log('Subscribed to the base topic:', this.topic); 30 | let re = new RegExp(`^${this.topic}-cascade-retry-.*`); 31 | yield this.consumer.subscribe({ topic: re, fromBeginning: this.fromBeginning }); 32 | console.log('Connected to the retry topics...'); 33 | resolve(true); 34 | } 35 | catch (error) { 36 | this.emit('error', error); 37 | reject(error); 38 | } 39 | })); 40 | } 41 | run(serviceCB, successCB, rejectCB) { 42 | return this.consumer.run({ eachMessage: (msg) => { 43 | try { 44 | if (!msg.message.headers) { 45 | msg.message.headers = {}; 46 | } 47 | if (!msg.message.headers.cascadeMetadata) { 48 | msg.message.headers.cascadeMetadata = JSON.stringify({ 49 | status: '', 50 | retries: 0, 51 | topicArr: [], 52 | }); 53 | } 54 | if (msg.topic === this.topic) 55 | this.emit('receive', msg); 56 | } 57 | catch (error) { 58 | this.emit('error', error); 59 | } 60 | try { 61 | serviceCB(msg, successCB, rejectCB); 62 | } 63 | catch (error) { 64 | this.emit('serviceError', error); 65 | } 66 | } }); 67 | } 68 | disconnect() { 69 | return this.consumer.disconnect(); 70 | } 71 | stop() { 72 | return this.consumer.stop(); 73 | } 74 | pause() { 75 | return this.consumer.pause(); 76 | } 77 | resume() { 78 | return this.consumer.resume(); 79 | } 80 | on(event, callback) { 81 | super.on(event, callback); 82 | } 83 | } 84 | exports.default = CascadeConsumer; 85 | -------------------------------------------------------------------------------- /demo/client/components/CascadeChart.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, useEffect } from 'react'; 2 | import Chart from 'chart.js/auto'; 3 | import socket from '../socket'; 4 | import { ChartConfiguration } from 'chart.js/types/index.esm'; 5 | import * as Scales from 'd3-scale-chromatic'; 6 | 7 | import './CascadeChart.scss'; 8 | 9 | interface ChartProps { 10 | 11 | } 12 | 13 | export const CascadeChart: FC = (props: ChartProps) => { 14 | 15 | useEffect(() => { 16 | const colorRangeInfo = { 17 | colorStart: 0, 18 | colorEnd: 0.9, 19 | useEndAsStart: true, 20 | }; 21 | 22 | // scale options: https://github.com/d3/d3-scale-chromatic 23 | const colorScale = Scales.interpolateInferno; 24 | let colors:any; 25 | 26 | const config = { 27 | type: 'bar', 28 | options: { 29 | indexAxis: 'y', 30 | plugins: { 31 | legend: { 32 | display: false, 33 | } 34 | }, 35 | scales: { 36 | y: { 37 | beginAtZero: true, 38 | grid: { 39 | display: false, 40 | } 41 | }, 42 | x: { 43 | grid: { 44 | display: false, 45 | } 46 | } 47 | }, 48 | }, 49 | data: { 50 | labels:[], 51 | datasets: [{ 52 | axis: 'y', 53 | backgroundColor: colors, 54 | borderColor: colors, 55 | data: [], 56 | borderWidth: 0, 57 | }] 58 | } 59 | } 60 | 61 | const chart = new Chart((document.getElementById('chartId') as HTMLCanvasElement), config as ChartConfiguration); 62 | socket.addListener('heartbeat', (payload:any) => { 63 | config.data.datasets[0].data = payload.levelCounts; 64 | 65 | if(config.data.labels.length !== payload.levelCounts.length) { 66 | colors = interpolateColors(payload.levelCounts.length, colorScale, colorRangeInfo); 67 | config.data.datasets[0].backgroundColor = colors; 68 | config.data.datasets[0].borderColor = colors; 69 | config.data.labels = buildLabels(payload.levelCounts.length); 70 | } 71 | 72 | chart.update(); 73 | }); 74 | }); 75 | 76 | return ( 77 |
    78 | 79 |
    80 | ); 81 | } 82 | 83 | function interpolateColors(dataLength:number, colorScale:any, colorRangeInfo: {colorStart: number, colorEnd: number, useEndAsStart:boolean}) { 84 | const { colorStart, colorEnd } = colorRangeInfo; 85 | const colorRange = colorEnd - colorStart; 86 | const colorArray = []; 87 | 88 | for (let i = 0; i < dataLength; i++) { 89 | const colorPoint = calculatePoint(i, colorRange / dataLength, colorRangeInfo); 90 | colorArray.push(colorScale(colorPoint)); 91 | } 92 | 93 | return colorArray; 94 | } 95 | 96 | function calculatePoint(i:number, intervalSize:number, colorRangeInfo: {colorStart: number, colorEnd: number, useEndAsStart:boolean}) { 97 | const { colorStart, colorEnd, useEndAsStart } = colorRangeInfo; 98 | return (useEndAsStart 99 | ? (colorEnd - (i * intervalSize)) 100 | : (colorStart + (i * intervalSize))); 101 | } 102 | 103 | function buildLabels(count:number) { 104 | const labels = new Array(count); 105 | labels[0] = 'Initial Success'; 106 | labels[count-1] = 'DLQ'; 107 | for(let i = 1; i < count - 1; i++) { 108 | if(i === 1) labels[i] = '1st Retry'; 109 | else if(i === 2) labels[i] = '2nd Retry'; 110 | else if(i === 3) labels[i] = '3rd Retry'; 111 | else labels[i] = i + 'th Retry'; 112 | } 113 | return labels; 114 | } 115 | -------------------------------------------------------------------------------- /dist/src/cascadeService.d.ts: -------------------------------------------------------------------------------- 1 | declare const EventEmitter: any; 2 | import * as Types from './kafkaInterface'; 3 | import CascadeProducer from './cascadeProducer'; 4 | import CascadeConsumer from './cascadeConsumer'; 5 | /** 6 | * CascadeService 7 | */ 8 | declare class CascadeService extends EventEmitter { 9 | kafka: Types.KafkaInterface; 10 | topic: string; 11 | serviceCB: Types.ServiceCallback; 12 | successCB: (...args: any[]) => any; 13 | dlqCB: Types.RouteCallback; 14 | producer: CascadeProducer; 15 | consumer: CascadeConsumer; 16 | events: string[]; 17 | /** 18 | * CascadeService objects should be constructed from [cascade.service]{@link module:cascade.service} 19 | */ 20 | constructor(kafka: Types.KafkaInterface, topic: string, groupId: string, serviceCB: Types.ServiceCallback, successCB: (...args: any[]) => any, dlqCB: Types.RouteCallback); 21 | /** 22 | * Connects the service to kafka 23 | * Emits a 'connect' event 24 | * @returns {Promise} 25 | */ 26 | connect(): Promise; 27 | /** 28 | * Disconnects the service from kafka 29 | * Emits a 'disconnect' event 30 | * @returns {Promise} 31 | */ 32 | disconnect(): Promise; 33 | /** 34 | * Sets the parameters for the default retry route or when an unknown status is provided when the service rejects the message. 35 | * Levels is the number of times a message can be retried before being sent the DLQ callback. 36 | * Options can contain timeoutLimit as a number array. For each entry it will determine the delay for the message before it is retried. 37 | * Options can contain batchLimit as a number array. For each entry it will determine how many messages to wait for at the corresponding retry level before sending all pending messages at once. 38 | * If options is not provided then the default route is to have a batch limit of 1 for each retry level. 39 | * If both timeoutLimit and batchLimit are provided then timeoutLimit takes precedence 40 | * @param {number} levels - number of retry levels before the message is sent to the DLQ 41 | * @param {object} options - sets the retry strategies of the levels 42 | * @returns {promise} 43 | */ 44 | setDefaultRoute(levels: number, options?: { 45 | timeoutLimit?: number[]; 46 | batchLimit?: number[]; 47 | }): Promise; 48 | /** 49 | * Sets additional routes for the retry strategies when a status is provided when the message is rejected in the service callback. 50 | * See 'setDefaultRoute' for a discription of the parameters 51 | * @param {string} status - status code used to trigger this route 52 | * @param {number} levels - number of retry levels before the message is sent to the DLQ 53 | * @param {object} options - sets the retry strategies of the levels 54 | * @returns {Promise} 55 | */ 56 | setRoute(status: string, levels: number, options?: { 57 | timeoutLimit?: number[]; 58 | batchLimit?: number[]; 59 | }): Promise; 60 | /** 61 | * Returns a list of all of the kafka topics that this service has created 62 | * @returns {string[]} 63 | */ 64 | getKafkaTopics(): string[]; 65 | /** 66 | * Invokes the server to start listening for messages. 67 | * Equivalent to consumer.run 68 | * @returns {Promise} 69 | */ 70 | run(): Promise; 71 | /** 72 | * Stops the service, any pending retry messages will be sent to the DLQ 73 | * @returns {Promise} 74 | */ 75 | stop(): Promise; 76 | /** 77 | * Pauses the service, any messages pending for retries will be held until the service is resumed 78 | * @returns {Promise} 79 | */ 80 | pause(): Promise; 81 | /** 82 | * 83 | * @returns {boolean} 84 | */ 85 | paused(): boolean; 86 | /** 87 | * Resumes the service, any paused retry messages will be retried 88 | * @returns {Promise} 89 | */ 90 | resume(): Promise; 91 | on(event: string, callback: (arg: any) => any): void; 92 | } 93 | export default CascadeService; 94 | -------------------------------------------------------------------------------- /docs/docs.md: -------------------------------------------------------------------------------- 1 | 2 | kafka-cascade is a lightweight library built on top of [kafkajs](https://kafka.js.org/) to provide automatic message reprocessing from services utilizing Kafka. 3 | 4 | The basic flow of how kafka-cascade operates is as follows: 5 | ![kafka-cascade flow](kafka-cascade-flow.png "kafka-cascade flow") 6 | 7 | ## Basic Usage 8 | To create a kafka-cascade service you need to import the library: 9 | ``` 10 | const cascade = require('kafka'); 11 | const service = cascade.service(kafka, 'example-topic', 'example-group', serviceCB, successCB, dlqCB); 12 | ``` 13 | The `kafka` object is created from `kafkajs` and is passed into the service to create the consumer and producer required to process messages. 14 | 15 | ### Callbacks 16 | The service callback should have the following signature: 17 | `serviceCB(msg, resolve, reject)` 18 | `msg` is the kafka message that the consumer is listening for. `resolve` and `reject` are the callbacks to provide message reprocessing. 19 | `resolve` takes the `msg` as an argument and will call the success callback which can receive the `msg` as it's argument. 20 | `reject` has the following signature: `reject(msg, status = '')`. Reject will either reprocess the message or call the dead letter queue (DLQ) callback which can receive the `msg` as it's argument. 21 | 22 | ### Retry Strategies 23 | kafka-cascade supports two different retry strategies that can be set by calling `.setDefaultRoute` on the returned service. 24 | Levels: specifies how many times a message should be retried before calling the DLQ callback. Each level can have it's own retry strategy. 25 | - Timeout Limit: specifies how long to wait before retrying the message 26 | - Batch LimitL specifies how many messages the producer should wait for before sending all of the messages to be retried at once 27 | 28 | ### Routes 29 | kafka-cascade supports setting routes on the message retries for different status codes. By calling `.setRoute` on the service, kafka-cascade can utilize that route by suppling the status code when calling `reject` from the service callback 30 | 31 | ## Example 32 | ``` 33 | const cascade = require('kafka-cascade'); 34 | 35 | // create the service callback to simulate some time consumming task 36 | const serviceCB = (msg, resolve, reject) => { 37 | performTimeConsumingTask(msg) 38 | .then(res => resolve(res)) 39 | .catch(error => { 40 | if(error === 'timeout') reject(msg, error); 41 | else reject(msg); 42 | }); 43 | }; 44 | 45 | // the service callback will be called with any arguments passed in through the resolve callback 46 | const successCB = (res) => { 47 | console.log('success:', res); 48 | }; 49 | 50 | // the dead letter queue call back will be called when a message is out of retries 51 | const dlqCB = (msg) => { 52 | console.log('failed:', msg); 53 | } 54 | 55 | // create a service with previously established kafka object 56 | const service = await cascade.service(kafka, 'example-topic', 'example-group', serviceCB, successCB, dlqCB); 57 | 58 | // establish a default route with a 5 levels and a timeout strategy 59 | await service.setDefaultRoute(5, {timeoutLimit: [500, 1000, 2000, 4000, 8000]}); 60 | 61 | // establish a 'timeout' route which goes directly to the DLQ 62 | await service.setRoute('timeout', 0); 63 | 64 | // connect and start the service 65 | await service.connect(); 66 | await service.run(); 67 | ``` 68 | ## Events 69 | The following events can be registered onto the CascadeService object 70 | ### connect() 71 | Emitted when the service succesfully connects to Kafka 72 | ### disconnect() 73 | Emitted when the service succesfully connects to Kafka 74 | ### run() 75 | Emitted when the service succesfully executes consumer.run 76 | ### stop() 77 | Emitted when the service succesfully stops 78 | ### pause() 79 | Emitted when the service is paused 80 | ### resume() 81 | Emitted when the service is resumed 82 | ### receive(msg) 83 | Emitted when the service receives a Kafka message on the base topic 84 | ### success(msg) 85 | Emitted when the service callback resolves a message 86 | ### retry(msg) 87 | Emitted when a rejected message is retried 88 | ### dlq(msg) 89 | Emitted when a reject message is sent to the DLQ 90 | ### error(error) 91 | Emitted on error within the library 92 | ### serviceError(error) 93 | Emitted when a uncaught error occurs within the service callback -------------------------------------------------------------------------------- /__tests__/cascade.retrystrat.test.ts: -------------------------------------------------------------------------------- 1 | const cascade = require('../kafka-cascade/index'); 2 | import * as Types from '../kafka-cascade/src/kafkaInterface'; 3 | import { TestKafka, TestProducer } from './cascade.mockclient.test'; 4 | 5 | console.log = jest.fn(); 6 | process.env.test = 'test'; 7 | 8 | describe('Testing timeout retry strategy', () => { 9 | let kafka: TestKafka; 10 | let testService: any; 11 | 12 | beforeEach(() => { 13 | kafka = new TestKafka(); 14 | }); 15 | 16 | it('Always fail on the sendTimeout route', async () => { 17 | const serviceAction = (msg: Types.KafkaConsumerMessageInterface, resolve: any, reject: any) => { 18 | try { 19 | reject(msg); 20 | } 21 | catch(error) { 22 | console.log('Caught error in service CB: ' + error); 23 | } 24 | } 25 | 26 | const dlq = jest.fn(); 27 | 28 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, jest.fn(), dlq); 29 | const retryLevels = 2; 30 | await testService.setDefaultRoute(retryLevels, { timeoutLimit:(new Array(retryLevels).fill(1)) } ); 31 | await testService.connect(); 32 | await testService.run(); 33 | 34 | const producer = kafka.producer(); 35 | const messageCount = 10; 36 | 37 | for(let i = 0; i < messageCount; i++) { 38 | await producer.send({ 39 | topic: 'test-topic', 40 | messages: [{ 41 | value: 'test message', 42 | }], 43 | }); 44 | } 45 | 46 | 47 | expect(producer.offsets['test-topic'].count).toBe(messageCount); 48 | const testServiceOffsets = testService.producer.producer.offsets; 49 | expect(Object.keys(testServiceOffsets)).toHaveLength(retryLevels); 50 | for(let topic in testServiceOffsets) { 51 | expect(testServiceOffsets[topic].count).toBe(messageCount); 52 | } 53 | expect(dlq).toHaveBeenCalledTimes(messageCount); 54 | }) 55 | }); 56 | 57 | describe('Testing batching retry strategy', () => { 58 | let kafka: TestKafka; 59 | let testService: any; 60 | let producer: TestProducer; 61 | let messageCount: number; 62 | let retryLevels: number; 63 | let dlq: any 64 | 65 | beforeAll(async () => { 66 | kafka = new TestKafka(); 67 | 68 | const serviceAction = (msg: Types.KafkaConsumerMessageInterface, resolve: any, reject: any) => { 69 | try { 70 | reject(msg); 71 | } 72 | catch(error) { 73 | console.log('Caught error in service CB: ' + error); 74 | } 75 | } 76 | 77 | dlq = jest.fn(); 78 | 79 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, jest.fn(), dlq); 80 | retryLevels = 2; 81 | messageCount = 10; 82 | await testService.setDefaultRoute(retryLevels, { batchLimit:(new Array(retryLevels).fill(messageCount)) } ); 83 | await testService.connect(); 84 | await testService.run(); 85 | 86 | producer = kafka.producer(); 87 | for(let i = 0; i < messageCount - 1; i++) { 88 | await producer.send({ 89 | topic: 'test-topic', 90 | messages: [{ 91 | value: 'test message', 92 | }], 93 | }); 94 | } 95 | }); 96 | 97 | it('MessageCount is not change before the number of messages equals to batch number', () => { 98 | expect(producer.offsets['test-topic'].count).toBe(messageCount - 1); 99 | const testServiceOffsets = testService.producer.producer.offsets; 100 | expect(Object.keys(testServiceOffsets)).toHaveLength(0); 101 | expect(testService.producer.routes[0].levels[0].messages).toHaveLength(messageCount - 1); 102 | expect(dlq).not.toHaveBeenCalled(); 103 | }); 104 | 105 | it('MessageCount is incremented when the number of messages equals to batch number', async () => { 106 | await producer.send({ 107 | topic: 'test-topic', 108 | messages: [{ 109 | value: 'test message', 110 | }], 111 | }); 112 | 113 | expect(producer.offsets['test-topic'].count).toBe(messageCount); 114 | const testServiceOffsets = testService.producer.producer.offsets; 115 | expect(Object.keys(testServiceOffsets)).toHaveLength(retryLevels); 116 | for(let topic in testServiceOffsets) { 117 | expect(testServiceOffsets[topic].count).toBe(messageCount); 118 | } 119 | expect(dlq).toHaveBeenCalledTimes(messageCount); 120 | }); 121 | 122 | }); -------------------------------------------------------------------------------- /demo/client/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { OptionContainer } from './containers/OptionContainer'; 3 | import FeaturesContainer from './containers/FeaturesContainer'; 4 | import AboutContainer from './containers/AboutContainer'; 5 | import GettingStartedContainer from './containers/GettingStartedContainer'; 6 | import { CascadeChart } from './components/CascadeChart'; 7 | import NavBar from './components/NavBar'; 8 | import socket from './socket'; 9 | import { Element } from 'react-scroll'; 10 | import { 11 | createStyles, makeStyles, Typography, Container, 12 | } from '@material-ui/core'; 13 | import { createTheme, ThemeProvider } from '@material-ui/core/styles' 14 | 15 | import './App.scss'; 16 | 17 | const theme = createTheme({ 18 | palette: { 19 | primary: { 20 | main:'#440057' 21 | }, 22 | secondary: { 23 | main: '#efde46' 24 | }, 25 | }, 26 | }); 27 | 28 | const useStyles = makeStyles(() => createStyles({ 29 | landing: { 30 | display: 'flex', 31 | flexDirection: 'column', 32 | justifyContent: 'center', 33 | alignItems: 'center', 34 | paddingTop: '20vh', 35 | paddingBottom: '10vh', 36 | }, 37 | container: { 38 | display: 'flex', 39 | flexDirection: 'column', 40 | justifyContent: 'center', 41 | alignItems: 'center', 42 | paddingTop: '3vh', 43 | paddingBottom: '3vh', 44 | }, 45 | })); 46 | 47 | interface AppProps { 48 | 49 | } 50 | 51 | interface AppState { 52 | loading:string, 53 | } 54 | 55 | export const App: FC = () => { 56 | const [state, setState] = useState({loading:'start'}) 57 | 58 | if(state.loading === 'start') { 59 | socket.connect() 60 | .then(res => setState({loading:'ready'})) 61 | .catch(error => { 62 | console.log('Error connecting to websocket server:', error); 63 | setState({loading:'error'}); 64 | }); 65 | setState({loading:'loading'}); 66 | } 67 | 68 | if(state.loading === 'loading') { 69 | return ( 70 |
    71 |
    72 | 73 | 74 | 75 | 76 | 77 | 78 |
    79 |
    80 |
    81 | ); 82 | } 83 | else if(state.loading === 'error') { 84 | return ( 85 |

    Error occured connecting to websocket server, make sure the server port is accessible

    86 | ); 87 | } 88 | else { 89 | const classes = useStyles(); 90 | return ( 91 | 92 | 93 | 94 | 95 | 101 | Kafka Cascade 102 | 103 | kafka-cascade-logo 104 | 110 | Message Reprocessing Library for KafkaJS 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 126 | Web Demo 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | ); 137 | } 138 | } -------------------------------------------------------------------------------- /__tests__/cascade.routes.test.ts: -------------------------------------------------------------------------------- 1 | const cascade = require('../kafka-cascade/index'); 2 | import * as Types from '../kafka-cascade/src/kafkaInterface'; 3 | import { TestKafka } from './cascade.mockclient.test'; 4 | 5 | 6 | console.log = jest.fn(); 7 | process.env.test = 'test'; 8 | 9 | describe('Routes Tests', () => { 10 | let kafka: TestKafka; 11 | let testService: any; 12 | 13 | beforeEach(() => { 14 | kafka = new TestKafka(); 15 | }); 16 | 17 | it('Can create a route', async () => { 18 | const serviceAction = (msg: Types.KafkaConsumerMessageInterface, resolve: any, reject: any) => { 19 | reject(msg, 'timeout'); 20 | } 21 | const dlq = jest.fn(); 22 | 23 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, jest.fn(), dlq); 24 | const retryLevels = 5; 25 | await testService.setDefaultRoute(retryLevels); 26 | 27 | await testService.setRoute('timeout', 0); 28 | await testService.connect(); 29 | await testService.run(); 30 | 31 | const producer = kafka.producer(); 32 | const messageCount = 10; 33 | for(let i = 0; i < messageCount; i++) { 34 | await producer.send({ 35 | topic: 'test-topic', 36 | messages: [{ 37 | value: 'test message', 38 | }], 39 | }); 40 | } 41 | 42 | expect(producer.offsets['test-topic'].count).toBe(messageCount); 43 | const testServiceOffsets = testService.producer.producer.offsets; 44 | expect(Object.keys(testServiceOffsets)).toHaveLength(0); 45 | for(let topic in testServiceOffsets) { 46 | expect(testServiceOffsets[topic].count).toBe(messageCount); 47 | } 48 | expect(dlq).toHaveBeenCalledTimes(messageCount); 49 | }); 50 | 51 | it('Uses default route when status is unknown', async () => { 52 | const serviceAction = (msg: Types.KafkaConsumerMessageInterface, resolve: any, reject: any) => { 53 | reject(msg, 'test-route'); 54 | } 55 | const dlq = jest.fn(); 56 | 57 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, jest.fn(), dlq); 58 | const retryLevels = 5; 59 | await testService.setDefaultRoute(retryLevels); 60 | 61 | await testService.connect(); 62 | await testService.run(); 63 | 64 | const producer = kafka.producer(); 65 | const messageCount = 10; 66 | 67 | for(let i = 0; i < messageCount; i++) { 68 | await producer.send({ 69 | topic: 'test-topic', 70 | messages: [{ 71 | value: 'test message', 72 | }], 73 | }); 74 | } 75 | 76 | expect(producer.offsets['test-topic'].count).toBe(messageCount); 77 | const testServiceOffsets = testService.producer.producer.offsets; 78 | expect(Object.keys(testServiceOffsets)).toHaveLength(retryLevels); 79 | for(let topic in testServiceOffsets) { 80 | expect(testServiceOffsets[topic].count).toBe(messageCount); 81 | } 82 | expect(dlq).toHaveBeenCalledTimes(messageCount); 83 | }); 84 | 85 | it('Can create route with multiple levels', async () => { 86 | const serviceAction = (msg: Types.KafkaConsumerMessageInterface, resolve: any, reject: any) => { 87 | reject(msg, 'test-route'); 88 | } 89 | const dlq = jest.fn(); 90 | 91 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, jest.fn(), dlq); 92 | const retryLevels = 5; 93 | await testService.setDefaultRoute(retryLevels); 94 | 95 | const routeLevels = 3; 96 | await testService.setRoute('test-route', routeLevels); 97 | await testService.connect(); 98 | await testService.run(); 99 | 100 | const producer = kafka.producer(); 101 | const messageCount = 10; 102 | for(let i = 0; i < messageCount; i++) { 103 | await producer.send({ 104 | topic: 'test-topic', 105 | messages: [{ 106 | value: 'test message', 107 | }], 108 | }); 109 | } 110 | 111 | expect(producer.offsets['test-topic'].count).toBe(messageCount); 112 | const testServiceOffsets = testService.producer.producer.offsets; 113 | expect(Object.keys(testServiceOffsets)).toHaveLength(routeLevels); 114 | for(let topic in testServiceOffsets) { 115 | expect(testServiceOffsets[topic].count).toBe(messageCount); 116 | expect(topic.search(new RegExp(/route-test-route/))).not.toBe(-1); 117 | } 118 | expect(dlq).toHaveBeenCalledTimes(messageCount); 119 | }); 120 | 121 | it('Can generate all of the kafka topics', async () => { 122 | testService = await cascade.service(kafka, 'test-topic', 'test-group', jest.fn(), jest.fn(), jest.fn()); 123 | const retryLevels = 5; 124 | await testService.setDefaultRoute(retryLevels); 125 | 126 | const routeLevels = 3; 127 | await testService.setRoute('test-route', routeLevels); 128 | 129 | const topics = testService.getKafkaTopics(); 130 | expect(topics).toHaveLength(retryLevels + routeLevels); 131 | }); 132 | }); -------------------------------------------------------------------------------- /__tests__/cascade.mockclient.test.ts: -------------------------------------------------------------------------------- 1 | import * as Types from '../kafka-cascade/src/kafkaInterface'; 2 | 3 | var testFn:any; 4 | if(process.env.NODE_ENV === 'production') { 5 | testFn = (callback:any) => function(...args) { return callback(...args); }; 6 | } 7 | else testFn = jest.fn; 8 | 9 | class TestKafka { 10 | subscribers: any[]; 11 | consumer: any; 12 | producer: any; 13 | admin:any; 14 | 15 | constructor() { 16 | this.subscribers = []; 17 | this.consumer = testFn(() => new TestConsumer(this)); 18 | this.producer = testFn(() => new TestProducer(this)); 19 | this.admin = testFn(() => new TestAdmin()); 20 | } 21 | } 22 | 23 | class TestConsumer { 24 | connect:any; 25 | disconnect: any; 26 | subscribe: any; 27 | run: any; 28 | stop: any; 29 | pause: any; 30 | resume: any; 31 | paused:boolean = false; 32 | 33 | constructor(kafka: TestKafka) { 34 | this.connect = testFn(() => { 35 | return new Promise((resolve) => resolve(true)); 36 | }); 37 | this.disconnect = testFn(() => { 38 | return new Promise((resolve) => resolve(true)); 39 | }); 40 | this.subscribe = testFn((sub) => { 41 | kafka.subscribers.push({topic: sub.topic, consumer:this }); 42 | return new Promise((resolve) => resolve(true)); 43 | }); 44 | this.run = testFn(options => { 45 | kafka.subscribers.forEach(c => { 46 | if(c.consumer === this) c.eachMessage = options.eachMessage; 47 | }); 48 | return new Promise((resolve) => resolve(true)); 49 | }); 50 | this.stop = testFn(() => { 51 | return new Promise((resolve) => resolve(true)); 52 | }); 53 | this.pause = testFn(() => { 54 | this.paused = true; 55 | return new Promise((resolve) => resolve(true)); 56 | }); 57 | this.resume = testFn(() => { 58 | this.paused = false; 59 | return new Promise((resolve) => resolve(true)); 60 | }); 61 | } 62 | } 63 | 64 | class TestProducer { 65 | connect: any; 66 | disconnect: any; 67 | send: any; 68 | partition = 0; 69 | offsets: {[details: string] : {count?:number}}; 70 | 71 | constructor(kafka: TestKafka) { 72 | this.connect = testFn(() => { 73 | return new Promise((resolve) => resolve(true)); 74 | }); 75 | this.disconnect = testFn(() => { 76 | return new Promise((resolve) => resolve(true)); 77 | }); 78 | this.offsets = {}; 79 | 80 | this.send = testFn((msg: Types.KafkaProducerMessageInterface) => { 81 | try { 82 | msg.messages.forEach(m => { 83 | if(!this.offsets[msg.topic]) this.offsets[msg.topic] = {count:0}; 84 | 85 | for(let i = 0; i < kafka.subscribers.length; i++) { 86 | const c = kafka.subscribers[i]; 87 | const consumerMsg:Types.KafkaConsumerMessageInterface = { 88 | topic: msg.topic, 89 | partition:this.partition, 90 | offset: this.offsets[msg.topic].count, 91 | message: m, 92 | } 93 | if(typeof(c.topic) === 'string' && c.topic === consumerMsg.topic) { 94 | c.eachMessage(consumerMsg); 95 | } 96 | else if(typeof(c.topic) !== 'string' && consumerMsg.topic.search(c.topic) > -1) { 97 | c.eachMessage(consumerMsg); 98 | } 99 | } 100 | this.offsets[msg.topic].count++; 101 | 102 | }); 103 | return new Promise((resolve) => resolve(true)); 104 | } 105 | catch(error) { 106 | console.log('test', 'Caught error in TestProducer.send: ' + error); 107 | return new Promise((resolve, reject) => reject(error)); 108 | } 109 | }); 110 | } 111 | } 112 | 113 | class TestAdmin { 114 | connect: any = testFn(() => { 115 | return new Promise((resolve) => resolve(true)); 116 | }); 117 | disconnect:any = testFn(() => { 118 | return new Promise((resolve) => resolve(true)); 119 | });; 120 | listTopics:any = testFn(() => { 121 | return new Promise((resolve) => resolve(['test-topic'])); 122 | }); 123 | createTopics:any = testFn(() => { 124 | return new Promise((resolve) => resolve(true)); 125 | }); 126 | deleteTopics:any = testFn(() => { 127 | return new Promise((resolve) => resolve(true)); 128 | }); 129 | } 130 | 131 | if(process.env.NODE_ENV !== 'production') test('Can create a test kafka object', () => { 132 | const kafka = new TestKafka(); 133 | const producer = kafka.producer(); 134 | const consumer = kafka.consumer(); 135 | consumer.subscribe({topic: 'test-topic'}); 136 | consumer.run({eachMessage:testFn()}); 137 | 138 | expect(Object.keys(producer.offsets)).toHaveLength(0); 139 | expect(kafka.subscribers).toHaveLength(1); 140 | expect(kafka.subscribers[0].topic).toEqual('test-topic'); 141 | expect(kafka.subscribers[0].consumer).toEqual(consumer); 142 | expect(kafka.subscribers[0].eachMessage).not.toHaveBeenCalled(); 143 | 144 | producer.send({ 145 | topic: 'test-topic', 146 | messages: [{ 147 | value: 'test message', 148 | }], 149 | }); 150 | expect(kafka.subscribers[0].eachMessage).toHaveBeenCalled(); 151 | expect(producer.offsets['test-topic'].count).toBe(1); 152 | }); 153 | 154 | export { 155 | TestKafka, 156 | TestConsumer, 157 | TestProducer, 158 | TestAdmin, 159 | } -------------------------------------------------------------------------------- /__tests__/cascade.events.test.ts: -------------------------------------------------------------------------------- 1 | const cascade = require('../kafka-cascade/index'); 2 | import * as Types from '../kafka-cascade/src/kafkaInterface'; 3 | import { TestKafka } from './cascade.mockclient.test'; 4 | 5 | const log = console.log; 6 | console.log = (test, ...args) => test === 'test' && log(args); 7 | // console.log = jest.fn(); 8 | process.env.test = 'test'; 9 | 10 | describe('Basic service tests', () => { 11 | let kafka: TestKafka; 12 | let testService: any; 13 | 14 | beforeEach(() => { 15 | kafka = new TestKafka(); 16 | }); 17 | 18 | it('Fires event on service run', async () => { 19 | testService = await cascade.service(kafka, 'test-topic', 'test-group', jest.fn(), jest.fn(), jest.fn()); 20 | const callbackTest = jest.fn(); 21 | testService.on('run', callbackTest); 22 | await testService.run(); 23 | expect(callbackTest).toHaveBeenCalled(); 24 | }); 25 | 26 | it('Throws an error when an unknown event type is registered', async () => { 27 | testService = await cascade.service(kafka, 'test-topic', 'test-group', jest.fn(), jest.fn(), jest.fn()); 28 | const callbackTest = jest.fn(); 29 | expect(() => testService.on('nothing', callbackTest)).toThrowError(); 30 | expect(callbackTest).not.toHaveBeenCalled(); 31 | }); 32 | 33 | it('Fires connect/disconnect events', async () => { 34 | testService = await cascade.service(kafka, 'test-topic', 'test-group', jest.fn(), jest.fn(), jest.fn()); 35 | const callbackTest = jest.fn(); 36 | testService.on('connect', callbackTest); 37 | testService.on('disconnect', callbackTest); 38 | await testService.connect(); 39 | await testService.disconnect(); 40 | expect(callbackTest).toHaveBeenCalledTimes(2); 41 | }); 42 | 43 | it('Fires pause/resume events', async () => { 44 | testService = await cascade.service(kafka, 'test-topic', 'test-group', jest.fn(), jest.fn(), jest.fn()); 45 | const callbackTest = jest.fn(); 46 | testService.on('pause', callbackTest); 47 | testService.on('resume', callbackTest); 48 | await testService.pause(); 49 | expect(testService.paused()).toBe(true); 50 | await testService.resume(); 51 | expect(testService.paused()).toBe(false); 52 | expect(callbackTest).toHaveBeenCalledTimes(2); 53 | }); 54 | 55 | it('Fires stop events', async () => { 56 | const retryLevels = 3; 57 | const messageCount = 5; 58 | const dlq = jest.fn(); 59 | const service = (msg, resolve, reject) => reject(msg); 60 | testService = await cascade.service(kafka, 'test-topic', 'test-group', service, jest.fn(), dlq); 61 | const callbackTest = jest.fn(); 62 | testService.on('stop', callbackTest); 63 | 64 | await testService.setDefaultRoute(retryLevels, { batchLimit:(new Array(2)).fill(messageCount) } ); 65 | await testService.connect(); 66 | await testService.run(); 67 | 68 | const producer = kafka.producer(); 69 | for(let i = 0; i < messageCount - 1; i++) { 70 | await producer.send({ 71 | topic: 'test-topic', 72 | messages: [{ 73 | value: 'test message', 74 | }], 75 | }); 76 | } 77 | try { 78 | await testService.stop(); 79 | } 80 | catch(error) { 81 | console.log('test', 'caught an error while sending:', error); 82 | } 83 | 84 | expect(callbackTest).toHaveBeenCalled(); 85 | testService.producer.routes[0].levels.forEach(level => { 86 | expect(level.messages).toHaveLength(0); 87 | }); 88 | const testServiceOffsets = testService.producer.producer.offsets; 89 | expect(Object.keys(testServiceOffsets)).toHaveLength(0); 90 | expect(dlq).toHaveBeenCalledTimes(messageCount - 1); 91 | }); 92 | 93 | it('Fires receive when new message comes in', async () => { 94 | testService = await cascade.service(kafka, 'test-topic', 'test-group', jest.fn(), jest.fn(), jest.fn()); 95 | const callbackTest = jest.fn(); 96 | testService.on('receive', callbackTest); 97 | await testService.connect(); 98 | await testService.run(); 99 | const producer = kafka.producer(); 100 | try { 101 | await producer.send({ 102 | topic: 'test-topic', 103 | messages: [{ 104 | value: 'test message', 105 | }], 106 | }); 107 | } 108 | catch(error) { 109 | console.log('test', 'caught an error while sending:', error); 110 | } 111 | 112 | expect(callbackTest).toHaveBeenCalled(); 113 | }); 114 | 115 | it('Fires success/retry/dlq with the message route', async () => { 116 | const serviceAction = (msg, resolve, reject) => { 117 | const {retries} = JSON.parse(msg.message.value); 118 | const header = JSON.parse(msg.message.headers.cascadeMetadata); 119 | if(header.retries === retries) resolve(msg); 120 | else reject(msg); 121 | }; 122 | 123 | testService = await cascade.service(kafka, 'test-topic', 'test-group', serviceAction, jest.fn(), jest.fn()); 124 | const retryCallback = jest.fn(); 125 | testService.on('retry', retryCallback); 126 | const successCB = jest.fn(); 127 | testService.on('success', successCB); 128 | const dlqCB = jest.fn(); 129 | testService.on('dlq', dlqCB); 130 | 131 | await testService.setDefaultRoute(3); 132 | await testService.connect(); 133 | await testService.run(); 134 | 135 | const producer = kafka.producer(); 136 | await producer.send({ 137 | topic: 'test-topic', 138 | messages: [{ 139 | value: JSON.stringify({retries:0}), 140 | }], 141 | }); 142 | 143 | await producer.send({ 144 | topic: 'test-topic', 145 | messages: [{ 146 | value: JSON.stringify({retries:2}), 147 | }], 148 | }); 149 | 150 | await producer.send({ 151 | topic: 'test-topic', 152 | messages: [{ 153 | value: JSON.stringify({retries:-1}), 154 | }], 155 | }); 156 | 157 | expect(successCB).toHaveBeenCalledTimes(2); 158 | expect(dlqCB).toHaveBeenCalledTimes(1); 159 | expect(retryCallback).toHaveBeenCalledTimes(5); 160 | }); 161 | 162 | it('Fires a serviceError if the service CB throws', async () => { 163 | const retryLevels = 1; 164 | const messageCount = 1; 165 | const service = (msg, resolve, reject) => { throw 'test error' }; 166 | testService = await cascade.service(kafka, 'test-topic', 'test-group', service, jest.fn(), jest.fn()); 167 | const callbackTest = jest.fn(); 168 | testService.on('serviceError', callbackTest); 169 | 170 | await testService.setDefaultRoute(retryLevels); 171 | await testService.connect(); 172 | await testService.run(); 173 | 174 | const producer = kafka.producer(); 175 | for(let i = 0; i < messageCount; i++) { 176 | await producer.send({ 177 | topic: 'test-topic', 178 | messages: [{ 179 | value: 'test message', 180 | }], 181 | }); 182 | } 183 | 184 | expect(callbackTest).toHaveBeenCalled(); 185 | }); 186 | }); -------------------------------------------------------------------------------- /demo/server/controllers/cascadeController.ts: -------------------------------------------------------------------------------- 1 | const { Kafka } = require('kafkajs'); 2 | const cascade = require('../../../kafka-cascade/index'); 3 | import * as Cascade from '../../../kafka-cascade/index'; 4 | import socket from '../websocket'; 5 | import { TestKafka } from '../../../__tests__/cascade.mockclient.test' 6 | 7 | var kafka: Cascade.Types.KafkaInterface; 8 | const brokers = process.env.KAFKA_BROKER_2 !== 'false' ? [process.env.KAFKA_BROKER_1, process.env.KAFKA_BROKER_2] : [process.env.KAFKA_BROKER_1]; 9 | if(process.env.DEMO === 'true') { 10 | kafka = new TestKafka(); 11 | } 12 | else kafka = new Kafka({ 13 | clientId: 'kafka-demo', 14 | brokers, 15 | }); 16 | console.log('Brokers:', brokers); 17 | 18 | const users: { [index: string]: { 19 | retryLevels:number, 20 | levelCounts:number[], 21 | topic:string, 22 | producer:Cascade.Types.ProducerInterface, 23 | service:Cascade.CascadeService, 24 | messageRate:number, 25 | } } = {}; 26 | 27 | // serviceCB simulates a realworld service by using the success value of the message to resolve or reject 28 | const serviceCB:Cascade.Types.ServiceCallback = (msg, resolve, reject) => { 29 | const message = JSON.parse(msg.message.value); 30 | const metadata = cascade.getMetadata(msg); 31 | if(!users[message.key] || metadata.retries > users[message.key].retryLevels) return; 32 | 33 | if(Math.random() < message.success) resolve(msg); 34 | else reject(msg); 35 | }; 36 | 37 | 38 | const successCB:Cascade.Types.RouteCallback = (msg) => { 39 | const message = JSON.parse(msg.message.value); 40 | const metadata = cascade.getMetadata(msg); 41 | if(users[message.key]) 42 | users[message.key].levelCounts[metadata.retries]++; 43 | }; 44 | const dlqCB:Cascade.Types.RouteCallback = (msg) => { 45 | const message = JSON.parse(msg.message.value); 46 | const user = users[message.key]; 47 | if(users[message.key]) 48 | user.levelCounts[user.levelCounts.length - 1]++; 49 | }; 50 | 51 | var service: Cascade.CascadeService; 52 | 53 | const startService = (key:string, retryLevels:number, options?: {timeoutLimit?:number[], batchLimit?:number[]}):Promise => { 54 | return new Promise(async (resolve, reject) => { 55 | try { 56 | const user = { 57 | retryLevels, 58 | levelCounts: (new Array(retryLevels+2)).fill(0), 59 | topic: 'test-topic-' + Array.from(key).filter(c => isAlphaNum(c)).join(''), 60 | producer: kafka.producer(), 61 | service: null, 62 | messageRate: 1, 63 | } 64 | users[key] = user; 65 | user.service = await cascade.service(kafka, user.topic, 'test-group-' + key, serviceCB, successCB, dlqCB); 66 | user.service.on('serviceError', (error) => console.log(error)); 67 | user.service.on('error', (error) => console.log(error)); 68 | user.service.on('error', (error) => console.log(error)); 69 | await user.service.setDefaultRoute(retryLevels, options); 70 | 71 | await user.service.connect(); 72 | await user.producer.connect(); 73 | console.log(`Connected ${key} to Kafka server...`); 74 | await user.service.run(); 75 | console.log(`${key} listening to Kafka server...`); 76 | resolve(true); 77 | } 78 | catch(error) { 79 | reject(error); 80 | } 81 | }); 82 | } 83 | 84 | const isAlphaNum = (c:string) => { 85 | let code = c.charCodeAt(0); 86 | return (code >= 'a'.charCodeAt(0) && code <= 'z'.charCodeAt(0)) || (code >= 'A'.charCodeAt(0) && code <= 'Z'.charCodeAt(0)) || (code >= '0'.charCodeAt(0) && code <= '9'.charCodeAt(0)); 87 | } 88 | 89 | const stopService = async (key:string) => { 90 | try { 91 | await users[key].producer.disconnect(); 92 | await users[key].service.disconnect(); 93 | } 94 | catch(error) { 95 | console.log(error); 96 | } 97 | users[key] = undefined; 98 | } 99 | 100 | const pauseService = async(key:string) => { 101 | if(!users[key].service.paused()){ 102 | await users[key].service.pause(); 103 | } 104 | } 105 | 106 | const resumeService = async(key:string) => { 107 | if(users[key].service.paused()){ 108 | await users[key].service.resume(); 109 | } 110 | } 111 | 112 | // start websocket functionality 113 | const sendMessageContinuous = async (key:string ) => { 114 | if(!users[key]) return; 115 | 116 | if(!users[key].service.paused()) { 117 | users[key].producer.send({ 118 | topic: users[key].topic, 119 | messages: [{ 120 | value: JSON.stringify({success: 0.3, key}), 121 | }], 122 | }) 123 | .catch(error => console.log('Error in sendMessageContinuous:', error)); 124 | } 125 | 126 | setTimeout(() => sendMessageContinuous(key), Math.round(1/users[key].messageRate * 1000)); 127 | }; 128 | 129 | socket.use('start', async (req, res) => { 130 | try { 131 | console.log(`Received start request from ${res.conn.key}`); 132 | console.log(req); 133 | if(!service) { 134 | await startService(res.conn.key, req.retries, req.options); 135 | sendMessageContinuous(res.conn.key); 136 | } 137 | } 138 | catch(error) { 139 | console.log(error); 140 | } 141 | }); 142 | 143 | socket.use('stop', (req, res) => { 144 | console.log(`Received stop request from ${res.conn.key}`); 145 | if(users[res.conn.key]) { 146 | stopService(res.conn.key); 147 | } 148 | }); 149 | 150 | socket.use('pause', (req, res) => { 151 | console.log(`Received pause request from ${res.conn.key}`); 152 | if(users[res.conn.key]) { 153 | pauseService(res.conn.key); 154 | } 155 | }) 156 | 157 | socket.use('resume', (req, res) => { 158 | console.log(`Received resume request from ${res.conn.key}`); 159 | if(users[res.conn.key]){ 160 | resumeService(res.conn.key); 161 | } 162 | }) 163 | 164 | socket.use('close', async (req, res) => { 165 | if(users[res.conn.key]) stopService(res.conn.key); 166 | console.log('Closed connection with:', res.conn.key); 167 | 168 | try { 169 | if(socket.server.connections.length === 0) { 170 | console.log('There are no active connections, cleaning up space...'); 171 | const admin = kafka.admin(); 172 | await admin.connect(); 173 | const topics = await admin.listTopics(); 174 | console.log('Topics to be delete:', topics) 175 | await admin.deleteTopics({topics}); 176 | 177 | await admin.disconnect(); 178 | console.log('Finished cleanup...'); 179 | } 180 | } 181 | catch(error) { 182 | console.log('Error in deleting topics:', error); 183 | } 184 | }); 185 | 186 | socket.use('set_rate', (req, res) => { 187 | if(users[res.conn.key]) 188 | users[res.conn.key].messageRate = req.rate; 189 | }); 190 | 191 | export const heartbeat = () => { 192 | for(let conn in socket.server.connections) { 193 | const c = socket.server.connections[conn]; 194 | let levelCounts:number[] = []; 195 | if(users[c.key]) { 196 | if(users[c.key].retryLevels + 2 !== users[c.key].levelCounts.length) { 197 | console.log(users[c.key].retryLevels + 2, ':', users[c.key].levelCounts.length); 198 | } 199 | levelCounts = users[c.key].levelCounts; 200 | } 201 | 202 | c.send(JSON.stringify({ 203 | type: 'heartbeat', 204 | payload: { 205 | levelCounts, 206 | } 207 | })); 208 | } 209 | 210 | setTimeout(heartbeat, 100); 211 | }; 212 | -------------------------------------------------------------------------------- /docs/index.ts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Kafka-Cascade Documentation index.ts 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
    37 |
    38 | 43 | 55 | 85 |
    86 |
    87 |
    88 | 99 |
    100 |
    101 |
    102 |

    Source

    103 |

    index.ts

    104 |
    105 | 106 | 107 | 108 | 109 | 110 |
    111 |
    112 |
    import CascadeService from './src/cascadeService';
    113 | import CascadeProducer from './src/cascadeProducer';
    114 | import CascadeConsumer from './src/cascadeConsumer'
    115 | import * as Types from './src/kafkaInterface';
    116 | 
    117 | /**
    118 |  * @module cascade
    119 |  */
    120 | module.exports = {
    121 |   /**
    122 |    * Main entry point to the module. Creates a new service that listens to and produces kafka messages
    123 |    * 
    124 |    * @example
    125 |    * const cascade = require('kafka-cascade');
    126 |    * const service = new cascade.service(kafka, 'example-topic', 'example-group', serviceCB, successCB, dlqCB);
    127 |    * 
    128 |    * @param kafka - KakfaJS Kafka Object
    129 |    * @param {string} topic - Topic that the service listens for and runs the service
    130 |    * @param {string} groupId - Group Id for the service consumer
    131 |    * @param serviceCB  - Callback that is run whenever 'topic' is received or retry. It accepts a kafka message, resolve callback and reject callback
    132 |    * @param successCB  - Callback that is run when the serviceCB resolves a message, accepts the kafka message
    133 |    * @param dlqCB - Callback that is run when the serviceCB rejects a message and cannot be retried anymore
    134 |    * @returns {CascadeService}
    135 |    */
    136 |   service: (kafka: Types.KafkaInterface, topic: string, groupId: string,
    137 |     serviceCB: Types.ServiceCallback, successCB: (...args: any[]) => any,
    138 |     dlqCB: Types.RouteCallback = (msg: Types.KafkaConsumerMessageInterface) => console.log('DLQ Message received')): Promise<CascadeService> => {
    139 |     
    140 |     return new Promise(async (resolve, reject) => {
    141 |       try {
    142 |         const newServ = new CascadeService(kafka, topic, groupId, serviceCB, successCB, dlqCB);
    143 |         resolve(newServ);
    144 |       }
    145 |       catch(error) {
    146 |         reject(error);
    147 |       }
    148 |     });
    149 |   },
    150 | 
    151 |   /**
    152 |    * Utility function that parses the metadata that cascade adds to the kafka message headers
    153 |    * @param msg 
    154 |    * @returns {object}
    155 |    */
    156 |   getMetadata: (msg: Types.KafkaConsumerMessageInterface):{ retires:number, status:string, topicArr:string[] } => {
    157 |     if(typeof(msg) !== 'object' || !msg.message || !msg.message.headers || !msg.message.headers.cascadeMetadata) return;
    158 |     return JSON.parse(msg.message.headers.cascadeMetadata);
    159 |   },
    160 | };
    161 | 
    162 | export {
    163 |   CascadeService,
    164 |   CascadeProducer,
    165 |   CascadeConsumer,
    166 |   Types,
    167 | };
    168 | 
    169 |
    170 |
    171 | 172 | 173 | 174 | 175 |
    176 | 177 | 186 | 187 |
    188 |
    189 |
    190 |
    191 | 192 | 193 | 194 | 195 | 196 | 197 | -------------------------------------------------------------------------------- /demo/client/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FC, useState, useEffect } from 'react'; 3 | import { 4 | createStyles, makeStyles, AppBar, Toolbar, IconButton, 5 | Button, Icon, Drawer, 6 | } from '@material-ui/core'; 7 | import MenuIcon from "@material-ui/icons/Menu"; 8 | import { Link } from 'react-scroll'; 9 | 10 | const useStyles = makeStyles(() => createStyles({ 11 | root: { 12 | width: '80vw', 13 | padding: 0, 14 | }, 15 | landingButtons: { 16 | display: 'flex', 17 | justifyContent: 'flex-start', 18 | }, 19 | drawerButtons: { 20 | display: 'flex', 21 | flexDirection: 'column', 22 | justifyContent: 'flex-start', 23 | }, 24 | logo: { 25 | alignSelf: 'flex-start', 26 | }, 27 | button: { 28 | margin: '1rem 1rem 1rem 1rem', 29 | }, 30 | })); 31 | 32 | const iconStyle = makeStyles(() => createStyles({ 33 | root:{ 34 | overflow: 'visible', 35 | display: 'flex', 36 | flexDirection: 'column', 37 | justifyContent: 'center', 38 | alignItems: 'center', 39 | } 40 | })); 41 | 42 | const goToDocs = () => { 43 | window.location.href = '/doc/index.html'; 44 | } 45 | 46 | const NavBar: FC = () => { 47 | const classes = useStyles(); 48 | const iconClass = iconStyle(); 49 | 50 | // initial values for condensed navbar view and button drawer 51 | const [state, setState] = useState({ 52 | mobileView: false, 53 | drawerOpen: false, 54 | }); 55 | 56 | // destructure mobileView and drawOpen 57 | const { mobileView, drawerOpen } = state; 58 | 59 | useEffect(() => { 60 | const setResponsiveness = () => { 61 | return window.innerWidth < 600 || screen.availWidth < 600 62 | ? setState((prevState) => ({ ...prevState, mobileView: true })) 63 | : setState((prevState) => ({ ...prevState, mobileView: false })); 64 | }; 65 | 66 | setResponsiveness(); 67 | window.addEventListener("resize", () => setResponsiveness()); 68 | 69 | return () => { 70 | window.removeEventListener("resize", () => setResponsiveness()); 71 | } 72 | }, []); 73 | 74 | // navbar display elements if the window is >600 pixels 75 | const displayDesktop = () => { 76 | return ( 77 | 78 | 79 | 86 | icon 92 | 93 | 94 | 105 | 116 | 127 | 138 | 149 | 150 | ); 151 | }; 152 | 153 | // navbar display elements if the window is <600 pixels 154 | const displayMobile = () => { 155 | const handleDrawerOpen = () => 156 | setState((prevState) => ({ ...prevState, drawerOpen: true })); 157 | const handleDrawerClose = () => 158 | setState((prevState) => ({ ...prevState, drawerOpen: false })); 159 | 160 | return ( 161 | 162 | 171 | 172 | 173 | 180 |
    181 | 191 | 201 | 211 | 221 | 231 |
    232 |
    233 | 234 | 241 | icon 247 | 248 | 249 |
    250 | ); 251 | }; 252 | 253 | return ( 254 |
    255 | 256 | {mobileView ? displayMobile() : displayDesktop()} 257 | 258 |
    259 | ); 260 | }; 261 | 262 | export default NavBar; -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Kafka-Cascade Documentation Home 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
    35 |
    36 | 41 | 53 | 83 |
    84 |
    85 |
    86 | 97 |
    98 |
    99 |
    100 |

    101 |

    Home

    102 |
    103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |

    111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 |
    127 |

    kafka-cascade is a lightweight library built on top of kafkajs to provide automatic message reprocessing from services utilizing Kafka.

    128 |

    The basic flow of how kafka-cascade operates is as follows: 129 | kafka-cascade flow

    130 |

    Basic Usage

    131 |

    To create a kafka-cascade service you need to import the library:

    132 |
    const cascade = require('kafka');
    133 | const service = cascade.service(kafka, 'example-topic', 'example-group', serviceCB, successCB, dlqCB);
    134 | 
    135 |

    The kafka object is created from kafkajs and is passed into the service to create the consumer and producer required to process messages.

    136 |

    Callbacks

    137 |

    The service callback should have the following signature: 138 | serviceCB(msg, resolve, reject) 139 | msg is the kafka message that the consumer is listening for. resolve and reject are the callbacks to provide message reprocessing. 140 | resolve takes the msg as an argument and will call the success callback which can receive the msg as it's argument. 141 | reject has the following signature: reject(msg, status = ''). Reject will either reprocess the message or call the dead letter queue (DLQ) callback which can receive the msg as it's argument.

    142 |

    Retry Strategies

    143 |

    kafka-cascade supports two different retry strategies that can be set by calling .setDefaultRoute on the returned service. 144 | Levels: specifies how many times a message should be retried before calling the DLQ callback. Each level can have it's own retry strategy.

    145 |
      146 |
    • Timeout Limit: specifies how long to wait before retrying the message
    • 147 |
    • Batch LimitL specifies how many messages the producer should wait for before sending all of the messages to be retried at once
    • 148 |
    149 |

    Routes

    150 |

    kafka-cascade supports setting routes on the message retries for different status codes. By calling .setRoute on the service, kafka-cascade can utilize that route by suppling the status code when calling reject from the service callback

    151 |

    Example

    152 |
    const cascade = require('kafka-cascade');
    153 | 
    154 | // create the service callback to simulate some time consumming task
    155 | const serviceCB = (msg, resolve, reject) => {
    156 |   performTimeConsumingTask(msg)
    157 |     .then(res => resolve(res))
    158 |     .catch(error => {
    159 |       if(error === 'timeout') reject(msg, error);
    160 |       else reject(msg);
    161 |     });
    162 | };
    163 | 
    164 | // the service callback will be called with any arguments passed in through the resolve callback
    165 | const successCB = (res) => {
    166 |   console.log('success:', res);
    167 | };
    168 | 
    169 | // the dead letter queue call back will be called when a message is out of retries
    170 | const dlqCB = (msg) => {
    171 |   console.log('failed:', msg);
    172 | }
    173 | 
    174 | // create a service with previously established kafka object
    175 | const service = await cascade.service(kafka, 'example-topic', 'example-group', serviceCB, successCB, dlqCB);
    176 | 
    177 | // establish a default route with a 5 levels and a timeout strategy
    178 | await service.setDefaultRoute(5, {timeoutLimit: [500, 1000, 2000, 4000, 8000]});
    179 | 
    180 | // establish a 'timeout' route which goes directly to the DLQ
    181 | await service.setRoute('timeout', 0);
    182 | 
    183 | // connect and start the service
    184 | await service.connect();
    185 | await service.run();
    186 | 
    187 |

    Events

    188 |

    The following events can be registered onto the CascadeService object

    189 |

    connect()

    190 |

    Emitted when the service succesfully connects to Kafka

    191 |

    disconnect()

    192 |

    Emitted when the service succesfully connects to Kafka

    193 |

    run()

    194 |

    Emitted when the service succesfully executes consumer.run

    195 |

    stop()

    196 |

    Emitted when the service succesfully stops

    197 |

    pause()

    198 |

    Emitted when the service is paused

    199 |

    resume()

    200 |

    Emitted when the service is resumed

    201 |

    receive(msg)

    202 |

    Emitted when the service receives a Kafka message on the base topic

    203 |

    success(msg)

    204 |

    Emitted when the service callback resolves a message

    205 |

    retry(msg)

    206 |

    Emitted when a rejected message is retried

    207 |

    dlq(msg)

    208 |

    Emitted when a reject message is sent to the DLQ

    209 |

    error(error)

    210 |

    Emitted on error within the library

    211 |

    serviceError(error)

    212 |

    Emitted when a uncaught error occurs within the service callback

    213 |
    214 | 215 | 216 | 217 | 218 | 219 | 220 |
    221 | 222 | 231 | 232 |
    233 |
    234 |
    235 |
    236 | 237 | 238 | 239 | 240 | 241 | -------------------------------------------------------------------------------- /kafka-cascade/src/cascadeService.ts: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | import * as Types from './kafkaInterface'; 3 | import CascadeProducer from './cascadeProducer'; 4 | import CascadeConsumer from './cascadeConsumer'; 5 | 6 | // kafka object to create producer and consumer 7 | // service callback 8 | // dlq callback -> provide default 9 | // success callback 10 | // topic 11 | // retry producer 12 | // topic consumer 13 | // retry levels -> provide default 14 | // retry strategies per level 15 | 16 | /** 17 | * CascadeService 18 | */ 19 | class CascadeService extends EventEmitter { 20 | kafka: Types.KafkaInterface; 21 | topic: string; 22 | serviceCB: Types.ServiceCallback; 23 | successCB: (...args: any[]) => any; 24 | dlqCB: Types.RouteCallback; 25 | producer: CascadeProducer; 26 | consumer: CascadeConsumer; 27 | 28 | events = [ 29 | 'connect', 30 | 'disconnect', 31 | 'run', 32 | 'stop', 33 | 'pause', 34 | 'resume', 35 | 'receive', 36 | 'success', 37 | 'retry', 38 | 'dlq', 39 | 'error', 40 | 'serviceError', 41 | ]; 42 | 43 | /** 44 | * CascadeService objects should be constructed from [cascade.service]{@link module:cascade.service} 45 | */ 46 | constructor(kafka: Types.KafkaInterface, topic: string, groupId: string, 47 | serviceCB: Types.ServiceCallback, successCB: (...args: any[]) => any, dlqCB: Types.RouteCallback) { 48 | super(); 49 | this.kafka = kafka; 50 | this.topic = topic; 51 | this.serviceCB = serviceCB; 52 | this.successCB = successCB; 53 | this.dlqCB = dlqCB; 54 | this.retries = 0; 55 | this.topicsArr = []; 56 | 57 | 58 | // create producers and consumers 59 | this.producer = new CascadeProducer(kafka, topic, dlqCB); 60 | this.producer.on('retry', (msg) => this.emit('retry', msg)); 61 | this.producer.on('dlq', (msg) => this.emit('dlq', msg)); 62 | this.producer.on('error', (error) => this.emit('error', 'Error in cascade producer: ' + error)); 63 | this.consumer = new CascadeConsumer(kafka, topic, groupId, false); 64 | this.consumer.on('receive', (msg) => this.emit('receive', msg)); 65 | this.consumer.on('serviceError', (error) => this.emit('serviceError', error)); 66 | this.consumer.on('error', (error) => this.emit('error', 'Error in cascade consumer: ' + error)); 67 | } 68 | 69 | /** 70 | * Connects the service to kafka 71 | * Emits a 'connect' event 72 | * @returns {Promise} 73 | */ 74 | connect():Promise { 75 | return new Promise(async (resolve, reject) => { 76 | try { 77 | await this.producer.connect(); 78 | await this.consumer.connect(); 79 | resolve(true); 80 | this.emit('connect'); 81 | } 82 | catch(error) { 83 | reject(error); 84 | this.emit('error', 'Error in cascade.connect(): ' + error); 85 | } 86 | }); 87 | } 88 | 89 | /** 90 | * Disconnects the service from kafka 91 | * Emits a 'disconnect' event 92 | * @returns {Promise} 93 | */ 94 | disconnect():Promise { 95 | return new Promise((resolve, reject) => { 96 | this.producer.stop() 97 | .then(() => { 98 | this.producer.disconnect() 99 | .then(() => { 100 | this.consumer.disconnect() 101 | .then(() => { 102 | resolve(true); 103 | this.emit('disconnect'); 104 | }) 105 | .catch(error => { 106 | reject(error); 107 | this.emit('error', 'Error in cascade.disconnect(): [CONSUMER]' + error); 108 | }); 109 | }) 110 | .catch(error => { 111 | reject(error); 112 | this.emit('error', 'Error in cascade.disconnect(): [PRODUCER:DISCONNECT]' + error); 113 | }); 114 | }) 115 | .catch(error => { 116 | reject(error); 117 | this.emit('error', 'Error in cascade.disconnect(): [PRODUCER:STOP]' + error); 118 | }); 119 | }); 120 | } 121 | 122 | /** 123 | * Sets the parameters for the default retry route or when an unknown status is provided when the service rejects the message. 124 | * Levels is the number of times a message can be retried before being sent the DLQ callback. 125 | * Options can contain timeoutLimit as a number array. For each entry it will determine the delay for the message before it is retried. 126 | * Options can contain batchLimit as a number array. For each entry it will determine how many messages to wait for at the corresponding retry level before sending all pending messages at once. 127 | * If options is not provided then the default route is to have a batch limit of 1 for each retry level. 128 | * If both timeoutLimit and batchLimit are provided then timeoutLimit takes precedence 129 | * @param {number} levels - number of retry levels before the message is sent to the DLQ 130 | * @param {object} options - sets the retry strategies of the levels 131 | * @returns {promise} 132 | */ 133 | setDefaultRoute(levels: number, options?: {timeoutLimit?: number[], batchLimit?: number[]}):Promise { 134 | return new Promise((resolve, reject) => { 135 | this.producer.setDefaultRoute(levels, options) 136 | .then(res => resolve(res)) 137 | .catch(error => { 138 | reject(error); 139 | this.emit('error', error); 140 | }); 141 | }); 142 | } 143 | 144 | /** 145 | * Sets additional routes for the retry strategies when a status is provided when the message is rejected in the service callback. 146 | * See 'setDefaultRoute' for a discription of the parameters 147 | * @param {string} status - status code used to trigger this route 148 | * @param {number} levels - number of retry levels before the message is sent to the DLQ 149 | * @param {object} options - sets the retry strategies of the levels 150 | * @returns {Promise} 151 | */ 152 | setRoute(status:string, levels: number, options?: {timeoutLimit?: number[], batchLimit?: number[]}):Promise { 153 | return new Promise((resolve, reject) => { 154 | this.producer.setRoute(status, levels, options) 155 | .then(res => resolve(res)) 156 | .catch(error => { 157 | reject(error); 158 | this.emit('error', error); 159 | }); 160 | }); 161 | } 162 | 163 | /** 164 | * Returns a list of all of the kafka topics that this service has created 165 | * @returns {string[]} 166 | */ 167 | getKafkaTopics():string[] { 168 | let topics:string[] = []; 169 | this.producer.routes.forEach(route => topics = topics.concat(route.topics)); 170 | return topics; 171 | } 172 | 173 | /** 174 | * Invokes the server to start listening for messages. 175 | * Equivalent to consumer.run 176 | * @returns {Promise} 177 | */ 178 | run():Promise { 179 | return new Promise(async (resolve, reject) => { 180 | try { 181 | const status = await this.consumer.run(this.serviceCB, 182 | (...args) => { this.emit('success', ...args); this.successCB(...args) }, 183 | async (msg, status:string = '') => { 184 | try { 185 | await this.producer.send(msg, status); 186 | } 187 | catch(error) { 188 | this.emit('error', 'Error in cascade producer.send(): ' + error); 189 | } 190 | }); 191 | resolve(status); 192 | this.emit('run'); 193 | } catch(error) { 194 | reject(error); 195 | this.emit('error', 'Error in cascade.run(): ' + error); 196 | } 197 | 198 | }); 199 | } 200 | 201 | /** 202 | * Stops the service, any pending retry messages will be sent to the DLQ 203 | * @returns {Promise} 204 | */ 205 | stop():Promise { 206 | return new Promise(async (resolve, reject) => { 207 | try { 208 | await this.consumer.stop(); 209 | await this.producer.stop(); 210 | 211 | resolve(true); 212 | this.emit('stop'); 213 | } catch (error) { 214 | reject(error); 215 | this.emit('error', 'Error in cascade.stop(): ' + error); 216 | } 217 | 218 | }); 219 | } 220 | 221 | /** 222 | * Pauses the service, any messages pending for retries will be held until the service is resumed 223 | * @returns {Promise} 224 | */ 225 | async pause():Promise { 226 | // check to see if service is already paused 227 | if (!this.producer.paused) { 228 | return new Promise (async (resolve, reject) => { 229 | try { 230 | await this.consumer.pause(); 231 | this.producer.pause(); 232 | resolve(true); 233 | this.emit('pause'); 234 | } catch (error) { 235 | reject(error); 236 | this.emit('error', 'Error in cascade.pause(): ' + error); 237 | } 238 | }); 239 | } else { 240 | console.log('cascade.pause() called while service is already paused!'); 241 | } 242 | } 243 | 244 | /** 245 | * 246 | * @returns {boolean} 247 | */ 248 | paused() { 249 | // return producer.paused boolean; 250 | return this.producer.paused; 251 | } 252 | 253 | /** 254 | * Resumes the service, any paused retry messages will be retried 255 | * @returns {Promise} 256 | */ 257 | async resume(): Promise { 258 | // check to see if service is paused 259 | if (this.producer.paused) { 260 | return new Promise(async (resolve, reject)=> { 261 | try{ 262 | await this.consumer.resume(); 263 | await this.producer.resume(); 264 | resolve(true); 265 | this.emit('resume'); 266 | } catch (error){ 267 | reject(error); 268 | this.emit('error', 'Error in cascade.resume(): ' + error); 269 | } 270 | }); 271 | } else { 272 | console.log('cascade.resume() called while service is already running!'); 273 | } 274 | } 275 | 276 | on(event: string, callback: (arg: any) => any) { 277 | if(!this.events.includes(event)) throw new Error('Unknown event: ' + event); 278 | super.on(event, callback); 279 | } 280 | } 281 | 282 | export default CascadeService; -------------------------------------------------------------------------------- /demo/client/containers/OptionContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC, useState} from 'react'; 2 | import { RadioButtonGroup } from '../components/RadioButtonGroup'; 3 | import { MessageSlider } from '../components/MessageSlider'; 4 | import Box from '@material-ui/core/Box'; 5 | import Button from '@material-ui/core/Button'; 6 | import TextField from '@material-ui/core/TextField'; 7 | import socket from '../socket'; 8 | import { RetryLevelTextField } from '../components/RetryLevelTextField'; 9 | import './OptionContainer.scss'; 10 | import { colors } from '@material-ui/core'; 11 | 12 | export const OptionContainer: FC = (props:any) => { 13 | //number of retry levels 14 | const [numberOfRetries, setNumberOfRetries] = useState(5) 15 | //set the maximum number of retry level 16 | const [retryLevelLIMIT, setRetryLevelLimit] = useState(10); 17 | //retry types 18 | const [retryType, setRetryType] = useState({ 19 | fastRetry: true, 20 | timeout: false, 21 | batching: false, 22 | }); 23 | //contains the number of messages that needs to be sent per second 24 | const [messagesPerSecond, setMessagesPerSecond] = useState(1); 25 | //contains each retry level 26 | const [timeoutLimitArray, setTimeoutLimitArray] = useState([1000, 2000, 4000, 8000, 16000]); 27 | const [batchLimitArray, setBatchLimitArray] = useState([6,6,6,6,6]); 28 | //Toggle visibility for retry option menu 29 | const [retryOptionToggle, setRetryOptionToggle] = useState(false); 30 | //used to toggle option buttons when demo starts AND used to prevent user from changing retry option 31 | const [isStarted, setIsStarted] = useState(false); 32 | //used to pause and resume demo 33 | const [isPaused, setIsPaused] = useState(false); 34 | //defaultParams, used by resetHandler 35 | const [defaultParams, isDefaultParams] = useState({numberOfRetries, retryType, timeoutLimitArray, batchLimitArray}) 36 | 37 | 38 | //used by RadioButtonGroup component, changes the type of retry 39 | const handleChange = (event: React.ChangeEvent) => { 40 | //prevents user from making change after starting the demo 41 | if(isStarted) return; 42 | let newRetryType = {...retryType}; 43 | newRetryType = { 44 | fastRetry: false, 45 | timeout: false, 46 | batching: false, 47 | } 48 | newRetryType[(event.target as HTMLInputElement).value] = true; 49 | setRetryType(newRetryType); 50 | }; 51 | 52 | //used by start button, initilize the options to the backend 53 | const startHandler = () => { 54 | if(!isStarted){ 55 | setRetryOptionToggle(false); 56 | let options: any; 57 | if(retryType.fastRetry) options = {}; 58 | else if(retryType.timeout) options = {timeoutLimit: timeoutLimitArray.map(t => Number(t))}; 59 | else options = {batchLimit: batchLimitArray.map(b => Number(b))}; 60 | socket.sendEvent('start', {retries: Number(numberOfRetries), options}); 61 | setIsStarted(true); 62 | } 63 | } 64 | 65 | //pause 66 | const pauseHandler = () => { 67 | if(!isPaused){ 68 | socket.sendEvent('pause', {}); 69 | setIsPaused(true); 70 | } 71 | } 72 | //resume 73 | const resumeHandler = () => { 74 | 75 | if(isPaused){ 76 | socket.sendEvent('resume', {}); 77 | setIsPaused(false); 78 | } 79 | } 80 | 81 | //stops the current session 82 | const stopHandler = () => { 83 | if(isStarted){ 84 | socket.sendEvent('stop', {}); 85 | setIsStarted(false); 86 | } 87 | } 88 | 89 | //reset 90 | const resetHandler = () => { 91 | if(isStarted) return; 92 | setNumberOfRetries(defaultParams.numberOfRetries); 93 | setRetryType(defaultParams.retryType); 94 | setTimeoutLimitArray(defaultParams.timeoutLimitArray); 95 | setBatchLimitArray(defaultParams.batchLimitArray) 96 | setRetryOptionToggle(false); 97 | } 98 | 99 | //set the number of messages sent 100 | const setMessagesPerSecondHandler = (event: any, rate: number) => { 101 | setMessagesPerSecond(rate); 102 | 103 | if(typeof(rate) !== 'string' && typeof(rate) !== 'number') rate = 1; 104 | else rate = Number(rate); 105 | socket.sendEvent('set_rate', {rate}); 106 | } 107 | 108 | //toggles visibility of the retry option menu 109 | const toggleRetryOptionContainer = () => { 110 | setRetryOptionToggle(!retryOptionToggle); 111 | } 112 | 113 | //updates the number of retrys 114 | const updateNumberOfRetriesHandler = (event) => { 115 | //prevents user from making change after starting the demo 116 | let value = event.target.value; 117 | if(isStarted || value < 0 || value > retryLevelLIMIT || value === '-'){ 118 | event.target.value = event.target.defaultValue; 119 | } 120 | else { 121 | event.target.defaultValue = value; 122 | setNumberOfRetries(event.target.value); 123 | if(timeoutLimitArray.length > value){ 124 | setTimeoutLimitArray(timeoutLimitArray.slice(0,value)) 125 | setBatchLimitArray(batchLimitArray.slice(0,value)) 126 | } 127 | else{ 128 | for(let i = timeoutLimitArray.length; i < value; i++){ 129 | timeoutLimitArray.push(1000); 130 | batchLimitArray.push(1); 131 | } 132 | } 133 | } 134 | } 135 | 136 | //updates the value of a retry level from the option menu 137 | const updateLimitArrayHandler = (event, index) => { 138 | //prevents user from making change after starting the demo 139 | let value = event.target.value; 140 | if(isStarted || value < 0 || value === '-'){ 141 | event.target.value = event.target.defaultValue; 142 | return; 143 | } else if(value > 0){ 144 | event.target.defaultValue = event.target.value; 145 | if(retryType.timeout){ 146 | setTimeoutLimitArray(() => {let copy = timeoutLimitArray; copy[index] = parseInt(event.target.value); return copy}) 147 | } 148 | if(retryType.batching) { 149 | setBatchLimitArray(() => {let copy = batchLimitArray; copy[index] = parseInt(event.target.value); return copy}) 150 | } 151 | } 152 | } 153 | 154 | //used to pick which type of retry level option to display 155 | let retryLevels:any[] = []; 156 | //contains rows for each retry level for timeout option 157 | let timeoutRetryLevel = []; 158 | //contains rows for each retry level for batching option 159 | let batchRetryLevel = []; 160 | 161 | //constructs each component rows for timeout and batching 162 | for(let i = 0; i < numberOfRetries; i++){ 163 | timeoutRetryLevel.push( 164 | 165 | ) 166 | batchRetryLevel.push( 167 | 168 | ) 169 | } 170 | 171 | //handles setting the correct retry level rows inside of retryLevels 172 | if(!retryType.fastRetry){ 173 | if(retryType.timeout) retryLevels = timeoutRetryLevel; 174 | else retryLevels = batchRetryLevel; 175 | } 176 | else{ 177 | retryLevels = []; 178 | } 179 | 180 | //container for retry level rows 181 | let retryLevelsContainer =
    ; 182 | //Makes retryLevelsContainer into a scrollable box if option is timeout or batching 183 | if(retryLevels.length) retryLevelsContainer =
    {retryLevels}
    184 | 185 | //Handles hidding and showing the option menu for retry level 186 | let retryOptionContainer =
    ; 187 | //if option button is clicked, retry options are added onto retryOptionContainer 188 | if(retryOptionToggle){ 189 | retryOptionContainer = ( 190 |
    191 |
    192 | 193 | {event.target.blur()}} 207 | /> 208 | 209 | 210 |
    211 |
    212 | {retryLevelsContainer} 213 |
    214 |
    215 | ) 216 | } 217 | 218 | 219 | let startPauseResumeToggle; 220 | let resetStopToggle; 221 | //Toggles different types of button depending on the mode of isStarted and isPaused 222 | if(isStarted){ 223 | resetStopToggle = 224 | if(isPaused) 225 | startPauseResumeToggle = 226 | else 227 | startPauseResumeToggle = 228 | } 229 | else{ 230 | resetStopToggle = 231 | startPauseResumeToggle = 232 | } 233 | 234 | return ( 235 |
    236 |
    237 | {startPauseResumeToggle} 238 | {resetStopToggle} 239 | 240 | 247 | 248 |
    249 | {retryOptionContainer} 250 |
    251 | 252 |
    253 |
    254 | ) 255 | } -------------------------------------------------------------------------------- /kafka-cascade/src/cascadeProducer.ts: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | import * as Types from './kafkaInterface'; 3 | import Queue from './util/queue'; 4 | 5 | class CascadeProducer extends EventEmitter { 6 | producer: Types.ProducerInterface; 7 | admin: Types.AdminInterface; 8 | topic: string; 9 | dlqCB: Types.RouteCallback; 10 | retryTopics: string[]; 11 | paused: boolean; 12 | pausedQueue: Queue<{msg:Types.KafkaConsumerMessageInterface, status:string}>; 13 | sendStorage = {}; 14 | routes: Types.ProducerRoute[]; 15 | 16 | constructor(kafka: Types.KafkaInterface, topic:string, dlqCB: Types.RouteCallback) { 17 | super(); 18 | this.topic = topic; 19 | this.dlqCB = dlqCB; 20 | this.retryTopics = []; 21 | this.producer = kafka.producer(); 22 | this.admin = kafka.admin(); 23 | this.paused = false; 24 | this.pausedQueue = new Queue<{msg:Types.KafkaConsumerMessageInterface, status:string}>(); 25 | // set the routes to have a default route 26 | this.routes = [{status:'', retryLevels:0, timeoutLimit: [], batchLimit: [], levels: [], topics:[]}]; 27 | } 28 | 29 | connect(): Promise { 30 | return this.producer.connect(); 31 | } 32 | 33 | disconnect(): Promise { 34 | return this.producer.disconnect(); 35 | } 36 | 37 | pause() { 38 | this.paused = true; 39 | } 40 | 41 | resume(): Promise { 42 | this.paused = false; 43 | // declare array to store promises 44 | const resumePromises = []; 45 | while (this.pausedQueue.length) { 46 | // push promise into promise array 47 | const {msg, status} = this.pausedQueue.shift(); 48 | resumePromises.push(this.send(msg, status)); 49 | } 50 | // return promise array 51 | return Promise.all(resumePromises); 52 | } 53 | 54 | stop(): Promise { 55 | // send all pending messages to DLQ 56 | for(let id in this.sendStorage) { 57 | if(this.sendStorage[id]) { 58 | let {msg} = this.sendStorage[id]; 59 | this.sendStorage[id] = undefined; 60 | this.dlqCB(msg); 61 | } 62 | } 63 | 64 | this.routes.forEach(route => { 65 | route.levels.forEach((level, i) => { 66 | level.messages.forEach(msg => { 67 | const conMsg = { 68 | topic: route.levels[i].topic, 69 | partition: -1, 70 | offset: -1, 71 | message: msg, 72 | } 73 | this.dlqCB(conMsg); 74 | }); 75 | level.messages = []; 76 | }); 77 | }); 78 | 79 | return new Promise((resolve) => resolve(true)); 80 | } 81 | 82 | send(msg: Types.KafkaConsumerMessageInterface, status:string): Promise { 83 | try{ 84 | 85 | if(this.paused) { 86 | this.pausedQueue.push({msg, status}); 87 | return new Promise(resolve => resolve(true)); 88 | } 89 | 90 | let route = this.routes[0]; 91 | for(let i = 1; i < this.routes.length; i++) { 92 | if(status === this.routes[i].status) { 93 | route = this.routes[i]; 94 | break; 95 | } 96 | } 97 | 98 | const metadata = JSON.parse(msg.message.headers.cascadeMetadata); 99 | if(metadata.status !== status) { 100 | metadata.retries = 0; 101 | metadata.status = status; 102 | } 103 | 104 | // check if retries exceeds allowed number of retries 105 | if (metadata.retries < route.topics.length) { 106 | 107 | msg.topic = route.topics[metadata.retries]; 108 | metadata.retries += 1; 109 | // populate producerMessage object 110 | let id = `${Date.now()}${Math.floor(Math.random() * Date.now())}`; 111 | const producerMessage = { 112 | topic: msg.topic, 113 | messages: [{ 114 | key: msg.message.key, 115 | value: msg.message.value, 116 | headers: { ...msg.message.headers, cascadeMetadata: JSON.stringify(metadata) } 117 | }] 118 | }; 119 | 120 | if(route.timeoutLimit[metadata.retries - 1] > 0) return this.sendTimeout(id, producerMessage, metadata.retries - 1, route); 121 | else return this.sendBatch(producerMessage, metadata.retries - 1, route); 122 | } else { 123 | this.emit('dlq', msg); 124 | this.dlqCB(msg); 125 | return new Promise((resolve) => resolve(true)); 126 | } 127 | } 128 | catch(error) { 129 | this.emit('error', error); 130 | } 131 | } 132 | 133 | // Used by send 134 | // Sets timedelay for producer messages 135 | sendTimeout(id:string, msg: Types.KafkaProducerMessageInterface, retries:number, route: Types.ProducerRoute) { 136 | return new Promise((resolve, reject) => { 137 | // Stores each send and msg to sendStorage[id] 138 | this.sendStorage[id] = { 139 | sending: () => { 140 | this.emit('retry', msg); 141 | this.producer.send(msg) 142 | .then(res => resolve(res)) 143 | .catch(res => { 144 | reject(res); 145 | }); 146 | }, msg: msg }; 147 | //sends message after timeout expires 148 | const scheduler = () => { 149 | if(this.sendStorage[id]){ 150 | const {sending} = this.sendStorage[id]; 151 | this.sendStorage[id] = undefined; 152 | sending(); 153 | } 154 | } 155 | if(process.env.test === 'test') scheduler(); 156 | else setTimeout(scheduler, route.timeoutLimit[retries]); 157 | }); 158 | }; 159 | 160 | // Used by send 161 | // sets batch limit before processing 162 | sendBatch(msg:Types.KafkaProducerMessageInterface, retries:number, route:Types.ProducerRoute){ 163 | return new Promise((resolve, reject) => { 164 | route.levels[retries].messages.push(msg.messages[0]); 165 | if(route.levels[retries].messages.length === route.batchLimit[retries]) { 166 | this.emit('retry', route.levels[retries]); 167 | this.producer.send(route.levels[retries]) 168 | .then(res => resolve(res)) 169 | .catch(res => { 170 | console.log('Caught an error trying to send batch: ' + res); 171 | reject(res); 172 | }); 173 | route.levels[retries].messages = []; 174 | } 175 | else resolve(true); 176 | }); 177 | } 178 | 179 | //set number of retries, timeoutLimit and batchLimit parameters 180 | setDefaultRoute(count:number, options?: {timeoutLimit?: number[], batchLimit?: number[]}):Promise { 181 | return new Promise(async (resolve, reject) => { 182 | try { 183 | const defaultRoute = this.routes[0]; 184 | if(defaultRoute.topics.length > count){ 185 | const diff = this.topicsArr.length - count; 186 | for(let i = 0; i < diff; i++){ 187 | defaultRoute.topics.pop(); 188 | }; 189 | } 190 | else { 191 | for(let i = defaultRoute.topics.length; i < count; i++){ 192 | defaultRoute.topics.push(this.topic + '-cascade-retry-' + (i+1)); 193 | } 194 | } 195 | defaultRoute.retryLevels = count; 196 | 197 | if(options && options.timeoutLimit) defaultRoute.timeoutLimit = options.timeoutLimit; 198 | else defaultRoute.timeoutLimit = (new Array(defaultRoute.topics.length)).fill(0); 199 | 200 | if(options && options.batchLimit) defaultRoute.batchLimit = options.batchLimit; 201 | else defaultRoute.batchLimit = (new Array(defaultRoute.topics.length)).fill(1); 202 | 203 | defaultRoute.levels = []; 204 | defaultRoute.topics.forEach((topic) => { 205 | const emptyMsg = { 206 | topic, 207 | messages: [], 208 | }; 209 | defaultRoute.levels.push(emptyMsg); 210 | }); 211 | 212 | 213 | // sets admin client to pre-register topics 214 | await this.admin.connect(); 215 | const registerTopics = { 216 | waitForLeaders: true, 217 | topics: [], 218 | } 219 | defaultRoute.topics.forEach(topic => registerTopics.topics.push({topic})); 220 | 221 | await this.admin.createTopics(registerTopics); 222 | const re = new RegExp(`^${this.topic}-cascade-retry-.*`); 223 | console.log('topics registered =', (await this.admin.listTopics()).filter(topic => topic === this.topic || topic.search(re) > -1)); 224 | await this.admin.disconnect(); 225 | 226 | setTimeout(() => { 227 | console.log('Registered topics with Kafka...'); 228 | resolve(true); 229 | }, 10); 230 | } 231 | catch(error) { 232 | this.emit('error', 'Error in cascade.setDefaultRoute(): ' + error); 233 | reject(error); 234 | } 235 | }); 236 | } 237 | 238 | setRoute(status:string, count:number, options?: {timeoutLimit?: number[], batchLimit?: number[]}):Promise { 239 | return new Promise(async (resolve, reject) => { 240 | try { 241 | const route:any = { status, retryLevels:count, topics:[] }; 242 | for(let i = 0; i < count; i++){ 243 | route.topics.push(this.topic + '-cascade-retry-' + 'route-' + status + '-' + (i+1)); 244 | } 245 | 246 | if(options && options.timeoutLimit) route.timeoutLimit = options.timeoutLimit; 247 | else route.timeoutLimit = (new Array(route.topics.length)).fill(0); 248 | 249 | if(options && options.batchLimit) route.batchLimit = options.batchLimit; 250 | else route.batchLimit = (new Array(route.topics.length)).fill(1); 251 | 252 | route.levels = []; 253 | route.topics.forEach((topic) => { 254 | const emptyMsg = { 255 | topic, 256 | messages: [], 257 | }; 258 | route.levels.push(emptyMsg); 259 | }); 260 | 261 | this.routes.push(route); 262 | 263 | // get an admin client to pre-register topics 264 | await this.admin.connect(); 265 | const registerTopics = { 266 | waitForLeaders: true, 267 | topics: [], 268 | } 269 | route.topics.forEach(topic => registerTopics.topics.push({topic})); 270 | 271 | await this.admin.createTopics(registerTopics); 272 | const re = new RegExp(`^${this.topic}-cascade-retry-.*`); 273 | console.log('topics registered =', (await this.admin.listTopics()).filter(topic => topic === this.topic || topic.search(re) > -1)); 274 | await this.admin.disconnect(); 275 | 276 | setTimeout(() => { 277 | console.log('Registered topics with Kafka...'); 278 | resolve(true); 279 | }, 10); 280 | } 281 | catch(error) { 282 | this.emit('error', 'Error in cascade.setRoute(): ' + error); 283 | reject(error); 284 | } 285 | }); 286 | } 287 | } 288 | 289 | export default CascadeProducer; 290 | -------------------------------------------------------------------------------- /dist/src/cascadeService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | const EventEmitter = require('events'); 13 | const cascadeProducer_1 = require("./cascadeProducer"); 14 | const cascadeConsumer_1 = require("./cascadeConsumer"); 15 | // kafka object to create producer and consumer 16 | // service callback 17 | // dlq callback -> provide default 18 | // success callback 19 | // topic 20 | // retry producer 21 | // topic consumer 22 | // retry levels -> provide default 23 | // retry strategies per level 24 | /** 25 | * CascadeService 26 | */ 27 | class CascadeService extends EventEmitter { 28 | /** 29 | * CascadeService objects should be constructed from [cascade.service]{@link module:cascade.service} 30 | */ 31 | constructor(kafka, topic, groupId, serviceCB, successCB, dlqCB) { 32 | super(); 33 | this.events = [ 34 | 'connect', 35 | 'disconnect', 36 | 'run', 37 | 'stop', 38 | 'pause', 39 | 'resume', 40 | 'receive', 41 | 'success', 42 | 'retry', 43 | 'dlq', 44 | 'error', 45 | 'serviceError', 46 | ]; 47 | this.kafka = kafka; 48 | this.topic = topic; 49 | this.serviceCB = serviceCB; 50 | this.successCB = successCB; 51 | this.dlqCB = dlqCB; 52 | this.retries = 0; 53 | this.topicsArr = []; 54 | // create producers and consumers 55 | this.producer = new cascadeProducer_1.default(kafka, topic, dlqCB); 56 | this.producer.on('retry', (msg) => this.emit('retry', msg)); 57 | this.producer.on('dlq', (msg) => this.emit('dlq', msg)); 58 | this.producer.on('error', (error) => this.emit('error', 'Error in cascade producer: ' + error)); 59 | this.consumer = new cascadeConsumer_1.default(kafka, topic, groupId, false); 60 | this.consumer.on('receive', (msg) => this.emit('receive', msg)); 61 | this.consumer.on('serviceError', (error) => this.emit('serviceError', error)); 62 | this.consumer.on('error', (error) => this.emit('error', 'Error in cascade consumer: ' + error)); 63 | } 64 | /** 65 | * Connects the service to kafka 66 | * Emits a 'connect' event 67 | * @returns {Promise} 68 | */ 69 | connect() { 70 | return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { 71 | try { 72 | yield this.producer.connect(); 73 | yield this.consumer.connect(); 74 | resolve(true); 75 | this.emit('connect'); 76 | } 77 | catch (error) { 78 | reject(error); 79 | this.emit('error', 'Error in cascade.connect(): ' + error); 80 | } 81 | })); 82 | } 83 | /** 84 | * Disconnects the service from kafka 85 | * Emits a 'disconnect' event 86 | * @returns {Promise} 87 | */ 88 | disconnect() { 89 | return new Promise((resolve, reject) => { 90 | this.producer.stop() 91 | .then(() => { 92 | this.producer.disconnect() 93 | .then(() => { 94 | this.consumer.disconnect() 95 | .then(() => { 96 | resolve(true); 97 | this.emit('disconnect'); 98 | }) 99 | .catch(error => { 100 | reject(error); 101 | this.emit('error', 'Error in cascade.disconnect(): [CONSUMER]' + error); 102 | }); 103 | }) 104 | .catch(error => { 105 | reject(error); 106 | this.emit('error', 'Error in cascade.disconnect(): [PRODUCER:DISCONNECT]' + error); 107 | }); 108 | }) 109 | .catch(error => { 110 | reject(error); 111 | this.emit('error', 'Error in cascade.disconnect(): [PRODUCER:STOP]' + error); 112 | }); 113 | }); 114 | } 115 | /** 116 | * Sets the parameters for the default retry route or when an unknown status is provided when the service rejects the message. 117 | * Levels is the number of times a message can be retried before being sent the DLQ callback. 118 | * Options can contain timeoutLimit as a number array. For each entry it will determine the delay for the message before it is retried. 119 | * Options can contain batchLimit as a number array. For each entry it will determine how many messages to wait for at the corresponding retry level before sending all pending messages at once. 120 | * If options is not provided then the default route is to have a batch limit of 1 for each retry level. 121 | * If both timeoutLimit and batchLimit are provided then timeoutLimit takes precedence 122 | * @param {number} levels - number of retry levels before the message is sent to the DLQ 123 | * @param {object} options - sets the retry strategies of the levels 124 | * @returns {promise} 125 | */ 126 | setDefaultRoute(levels, options) { 127 | return new Promise((resolve, reject) => { 128 | this.producer.setDefaultRoute(levels, options) 129 | .then(res => resolve(res)) 130 | .catch(error => { 131 | reject(error); 132 | this.emit('error', error); 133 | }); 134 | }); 135 | } 136 | /** 137 | * Sets additional routes for the retry strategies when a status is provided when the message is rejected in the service callback. 138 | * See 'setDefaultRoute' for a discription of the parameters 139 | * @param {string} status - status code used to trigger this route 140 | * @param {number} levels - number of retry levels before the message is sent to the DLQ 141 | * @param {object} options - sets the retry strategies of the levels 142 | * @returns {Promise} 143 | */ 144 | setRoute(status, levels, options) { 145 | return new Promise((resolve, reject) => { 146 | this.producer.setRoute(status, levels, options) 147 | .then(res => resolve(res)) 148 | .catch(error => { 149 | reject(error); 150 | this.emit('error', error); 151 | }); 152 | }); 153 | } 154 | /** 155 | * Returns a list of all of the kafka topics that this service has created 156 | * @returns {string[]} 157 | */ 158 | getKafkaTopics() { 159 | let topics = []; 160 | this.producer.routes.forEach(route => topics = topics.concat(route.topics)); 161 | return topics; 162 | } 163 | /** 164 | * Invokes the server to start listening for messages. 165 | * Equivalent to consumer.run 166 | * @returns {Promise} 167 | */ 168 | run() { 169 | return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { 170 | try { 171 | const status = yield this.consumer.run(this.serviceCB, (...args) => { this.emit('success', ...args); this.successCB(...args); }, (msg, status = '') => __awaiter(this, void 0, void 0, function* () { 172 | try { 173 | yield this.producer.send(msg, status); 174 | } 175 | catch (error) { 176 | this.emit('error', 'Error in cascade producer.send(): ' + error); 177 | } 178 | })); 179 | resolve(status); 180 | this.emit('run'); 181 | } 182 | catch (error) { 183 | reject(error); 184 | this.emit('error', 'Error in cascade.run(): ' + error); 185 | } 186 | })); 187 | } 188 | /** 189 | * Stops the service, any pending retry messages will be sent to the DLQ 190 | * @returns {Promise} 191 | */ 192 | stop() { 193 | return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { 194 | try { 195 | yield this.consumer.stop(); 196 | yield this.producer.stop(); 197 | resolve(true); 198 | this.emit('stop'); 199 | } 200 | catch (error) { 201 | reject(error); 202 | this.emit('error', 'Error in cascade.stop(): ' + error); 203 | } 204 | })); 205 | } 206 | /** 207 | * Pauses the service, any messages pending for retries will be held until the service is resumed 208 | * @returns {Promise} 209 | */ 210 | pause() { 211 | return __awaiter(this, void 0, void 0, function* () { 212 | // check to see if service is already paused 213 | if (!this.producer.paused) { 214 | return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { 215 | try { 216 | yield this.consumer.pause(); 217 | this.producer.pause(); 218 | resolve(true); 219 | this.emit('pause'); 220 | } 221 | catch (error) { 222 | reject(error); 223 | this.emit('error', 'Error in cascade.pause(): ' + error); 224 | } 225 | })); 226 | } 227 | else { 228 | console.log('cascade.pause() called while service is already paused!'); 229 | } 230 | }); 231 | } 232 | /** 233 | * 234 | * @returns {boolean} 235 | */ 236 | paused() { 237 | // return producer.paused boolean; 238 | return this.producer.paused; 239 | } 240 | /** 241 | * Resumes the service, any paused retry messages will be retried 242 | * @returns {Promise} 243 | */ 244 | resume() { 245 | return __awaiter(this, void 0, void 0, function* () { 246 | // check to see if service is paused 247 | if (this.producer.paused) { 248 | return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { 249 | try { 250 | yield this.consumer.resume(); 251 | yield this.producer.resume(); 252 | resolve(true); 253 | this.emit('resume'); 254 | } 255 | catch (error) { 256 | reject(error); 257 | this.emit('error', 'Error in cascade.resume(): ' + error); 258 | } 259 | })); 260 | } 261 | else { 262 | console.log('cascade.resume() called while service is already running!'); 263 | } 264 | }); 265 | } 266 | on(event, callback) { 267 | if (!this.events.includes(event)) 268 | throw new Error('Unknown event: ' + event); 269 | super.on(event, callback); 270 | } 271 | } 272 | exports.default = CascadeService; 273 | -------------------------------------------------------------------------------- /dist/src/cascadeProducer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | const EventEmitter = require('events'); 13 | const queue_1 = require("./util/queue"); 14 | class CascadeProducer extends EventEmitter { 15 | constructor(kafka, topic, dlqCB) { 16 | super(); 17 | this.sendStorage = {}; 18 | this.topic = topic; 19 | this.dlqCB = dlqCB; 20 | this.retryTopics = []; 21 | this.producer = kafka.producer(); 22 | this.admin = kafka.admin(); 23 | this.paused = false; 24 | this.pausedQueue = new queue_1.default(); 25 | // set the routes to have a default route 26 | this.routes = [{ status: '', retryLevels: 0, timeoutLimit: [], batchLimit: [], levels: [], topics: [] }]; 27 | } 28 | connect() { 29 | return this.producer.connect(); 30 | } 31 | disconnect() { 32 | return this.producer.disconnect(); 33 | } 34 | pause() { 35 | this.paused = true; 36 | } 37 | resume() { 38 | this.paused = false; 39 | // declare array to store promises 40 | const resumePromises = []; 41 | while (this.pausedQueue.length) { 42 | // push promise into promise array 43 | const { msg, status } = this.pausedQueue.shift(); 44 | resumePromises.push(this.send(msg, status)); 45 | } 46 | // return promise array 47 | return Promise.all(resumePromises); 48 | } 49 | stop() { 50 | // send all pending messages to DLQ 51 | for (let id in this.sendStorage) { 52 | if (this.sendStorage[id]) { 53 | let { msg } = this.sendStorage[id]; 54 | this.sendStorage[id] = undefined; 55 | this.dlqCB(msg); 56 | } 57 | } 58 | this.routes.forEach(route => { 59 | route.levels.forEach((level, i) => { 60 | level.messages.forEach(msg => { 61 | const conMsg = { 62 | topic: route.levels[i].topic, 63 | partition: -1, 64 | offset: -1, 65 | message: msg, 66 | }; 67 | this.dlqCB(conMsg); 68 | }); 69 | level.messages = []; 70 | }); 71 | }); 72 | return new Promise((resolve) => resolve(true)); 73 | } 74 | send(msg, status) { 75 | try { 76 | if (this.paused) { 77 | this.pausedQueue.push({ msg, status }); 78 | return new Promise(resolve => resolve(true)); 79 | } 80 | let route = this.routes[0]; 81 | for (let i = 1; i < this.routes.length; i++) { 82 | if (status === this.routes[i].status) { 83 | route = this.routes[i]; 84 | break; 85 | } 86 | } 87 | const metadata = JSON.parse(msg.message.headers.cascadeMetadata); 88 | if (metadata.status !== status) { 89 | metadata.retries = 0; 90 | metadata.status = status; 91 | } 92 | // check if retries exceeds allowed number of retries 93 | if (metadata.retries < route.topics.length) { 94 | msg.topic = route.topics[metadata.retries]; 95 | metadata.retries += 1; 96 | // populate producerMessage object 97 | let id = `${Date.now()}${Math.floor(Math.random() * Date.now())}`; 98 | const producerMessage = { 99 | topic: msg.topic, 100 | messages: [{ 101 | key: msg.message.key, 102 | value: msg.message.value, 103 | headers: Object.assign(Object.assign({}, msg.message.headers), { cascadeMetadata: JSON.stringify(metadata) }) 104 | }] 105 | }; 106 | if (route.timeoutLimit[metadata.retries - 1] > 0) 107 | return this.sendTimeout(id, producerMessage, metadata.retries - 1, route); 108 | else 109 | return this.sendBatch(producerMessage, metadata.retries - 1, route); 110 | } 111 | else { 112 | this.emit('dlq', msg); 113 | this.dlqCB(msg); 114 | return new Promise((resolve) => resolve(true)); 115 | } 116 | } 117 | catch (error) { 118 | this.emit('error', error); 119 | } 120 | } 121 | // Used by send 122 | // Sets timedelay for producer messages 123 | sendTimeout(id, msg, retries, route) { 124 | return new Promise((resolve, reject) => { 125 | // Stores each send and msg to sendStorage[id] 126 | this.sendStorage[id] = { 127 | sending: () => { 128 | this.emit('retry', msg); 129 | this.producer.send(msg) 130 | .then(res => resolve(res)) 131 | .catch(res => { 132 | reject(res); 133 | }); 134 | }, msg: msg 135 | }; 136 | //sends message after timeout expires 137 | const scheduler = () => { 138 | if (this.sendStorage[id]) { 139 | const { sending } = this.sendStorage[id]; 140 | this.sendStorage[id] = undefined; 141 | sending(); 142 | } 143 | }; 144 | if (process.env.test === 'test') 145 | scheduler(); 146 | else 147 | setTimeout(scheduler, route.timeoutLimit[retries]); 148 | }); 149 | } 150 | ; 151 | // Used by send 152 | // sets batch limit before processing 153 | sendBatch(msg, retries, route) { 154 | return new Promise((resolve, reject) => { 155 | route.levels[retries].messages.push(msg.messages[0]); 156 | if (route.levels[retries].messages.length === route.batchLimit[retries]) { 157 | this.emit('retry', route.levels[retries]); 158 | this.producer.send(route.levels[retries]) 159 | .then(res => resolve(res)) 160 | .catch(res => { 161 | console.log('Caught an error trying to send batch: ' + res); 162 | reject(res); 163 | }); 164 | route.levels[retries].messages = []; 165 | } 166 | else 167 | resolve(true); 168 | }); 169 | } 170 | //set number of retries, timeoutLimit and batchLimit parameters 171 | setDefaultRoute(count, options) { 172 | return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { 173 | try { 174 | const defaultRoute = this.routes[0]; 175 | if (defaultRoute.topics.length > count) { 176 | const diff = this.topicsArr.length - count; 177 | for (let i = 0; i < diff; i++) { 178 | defaultRoute.topics.pop(); 179 | } 180 | ; 181 | } 182 | else { 183 | for (let i = defaultRoute.topics.length; i < count; i++) { 184 | defaultRoute.topics.push(this.topic + '-cascade-retry-' + (i + 1)); 185 | } 186 | } 187 | defaultRoute.retryLevels = count; 188 | if (options && options.timeoutLimit) 189 | defaultRoute.timeoutLimit = options.timeoutLimit; 190 | else 191 | defaultRoute.timeoutLimit = (new Array(defaultRoute.topics.length)).fill(0); 192 | if (options && options.batchLimit) 193 | defaultRoute.batchLimit = options.batchLimit; 194 | else 195 | defaultRoute.batchLimit = (new Array(defaultRoute.topics.length)).fill(1); 196 | defaultRoute.levels = []; 197 | defaultRoute.topics.forEach((topic) => { 198 | const emptyMsg = { 199 | topic, 200 | messages: [], 201 | }; 202 | defaultRoute.levels.push(emptyMsg); 203 | }); 204 | // sets admin client to pre-register topics 205 | yield this.admin.connect(); 206 | const registerTopics = { 207 | waitForLeaders: true, 208 | topics: [], 209 | }; 210 | defaultRoute.topics.forEach(topic => registerTopics.topics.push({ topic })); 211 | yield this.admin.createTopics(registerTopics); 212 | const re = new RegExp(`^${this.topic}-cascade-retry-.*`); 213 | console.log('topics registered =', (yield this.admin.listTopics()).filter(topic => topic === this.topic || topic.search(re) > -1)); 214 | yield this.admin.disconnect(); 215 | setTimeout(() => { 216 | console.log('Registered topics with Kafka...'); 217 | resolve(true); 218 | }, 10); 219 | } 220 | catch (error) { 221 | this.emit('error', 'Error in cascade.setDefaultRoute(): ' + error); 222 | reject(error); 223 | } 224 | })); 225 | } 226 | setRoute(status, count, options) { 227 | return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { 228 | try { 229 | const route = { status, retryLevels: count, topics: [] }; 230 | for (let i = 0; i < count; i++) { 231 | route.topics.push(this.topic + '-cascade-retry-' + 'route-' + status + '-' + (i + 1)); 232 | } 233 | if (options && options.timeoutLimit) 234 | route.timeoutLimit = options.timeoutLimit; 235 | else 236 | route.timeoutLimit = (new Array(route.topics.length)).fill(0); 237 | if (options && options.batchLimit) 238 | route.batchLimit = options.batchLimit; 239 | else 240 | route.batchLimit = (new Array(route.topics.length)).fill(1); 241 | route.levels = []; 242 | route.topics.forEach((topic) => { 243 | const emptyMsg = { 244 | topic, 245 | messages: [], 246 | }; 247 | route.levels.push(emptyMsg); 248 | }); 249 | this.routes.push(route); 250 | // get an admin client to pre-register topics 251 | yield this.admin.connect(); 252 | const registerTopics = { 253 | waitForLeaders: true, 254 | topics: [], 255 | }; 256 | route.topics.forEach(topic => registerTopics.topics.push({ topic })); 257 | yield this.admin.createTopics(registerTopics); 258 | const re = new RegExp(`^${this.topic}-cascade-retry-.*`); 259 | console.log('topics registered =', (yield this.admin.listTopics()).filter(topic => topic === this.topic || topic.search(re) > -1)); 260 | yield this.admin.disconnect(); 261 | setTimeout(() => { 262 | console.log('Registered topics with Kafka...'); 263 | resolve(true); 264 | }, 10); 265 | } 266 | catch (error) { 267 | this.emit('error', 'Error in cascade.setRoute(): ' + error); 268 | reject(error); 269 | } 270 | })); 271 | } 272 | } 273 | exports.default = CascadeProducer; 274 | --------------------------------------------------------------------------------