Bull - rozproszone zadania w Node.js na poważnie

meet.js Warszawa #31

Warszawa, 22.08.2019

Łukasz Rybka

Co-founder / Chief Technology Officer

Trener

Full-stack JavaScript Developer

Teza

Node.js jest doskonałym środowiskiem do implementacji systemów rozproszonych

Mariusz

  • Programista JavaScript/TypeScript
  • Zazwyczaj realizuje projekty przy pomocy Angular, Nest/Express w architekturze monorepo (Nrwl Nx)
  • Rozpoczął niedawno pracę dla klienta potrzebującego systemu e-commerce dla swojej firmy

Planning #1

Klient prosi o przygotowanie początkowej wersji aplikacji w celu przetestowania zakupionego projektu graficznego.

Design aplikacji

App Controller

import { Controller, Get } from '@nestjs/common';

import { Product } from '@angular-store/api-interfaces';

import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('products')
  async getData(): Promise<Product[]> {
    return this.appService.getProducts()
  }
}

App Service

import { Injectable } from '@nestjs/common';

import { Product } from '@angular-store/api-interfaces';

@Injectable()
export class AppService {
  getProducts(): Promise<Product[]> {
    return Promise.resolve([
      {
        name: 'Stickers bag',
        price: 4.99,
        description: 'A collection of Angular stickers.'
      }
    ]);
  }
}

Planning #2

W trakcie kolejnej sesji planowania klient prosi Mariusza o przygotowanie strony z listą wszystkich zrealizowanych zamówień w danym miesiącu.

Design strony

Przykładowe zamówienie

const orders: Order[] = [
  {
    id: 1,
    orderDate: new Date(Date.parse('2019-07-11T16:15:00')),
    userEmail: 'customer1@dev.null',
    items: [
      { productId: 1, price: 4.00, quantity: 1 },
      { productId: 3, price: 3.99, quantity: 2 }
    ]
  }
];

Logika biznesowa

getOrders(): Promise<OrderWithTotal[]> {
  const orders: Order[] = [...];

  return Promise.resolve(orders.map(order => {
    const orderWithTotal: OrderWithTotal = { ...order, total: 0 };

    orderWithTotal.total = order.items.reduce((tot, item) => {
      return tot + item.quantity * item.price;
    }, 0);

    return orderWithTotal;
  }));
}

Planning #5

Po pewnym czasie funkcjonowania aplikacji na środowisku produkcyjnym klient zaobserwował, że wraz ze zwiększającą się ilością zamówień działanie strony z zamówieniami staje się coraz bardziej kłopotliwe.

Analiza problemu

HTTP 504

The HyperText Transfer Protocol (HTTP) 504 Gateway Timeout server error response code indicates that the server, while acting as a gateway or proxy, did not get a response in time from the upstream server that it needed in order to complete the request.

 

źródło: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504

Node.js server

By default, the Server's timeout value is 2 minutes, and sockets are destroyed automatically if they time out. However, if you assign a callback to the Server's 'timeout' event, then you are responsible for handling socket timeouts.

 

źródło: https://nodejs.org/dist/latest-v6.x/docs/api/http.html#http_server_settimeout_msecs_callback

Node.js server - "optymalizacja"

async function bootstrap() {
  const app = await NestFactory
    .create<NestExpressApplication>(AppModule);

  app.getHttpServer().setTimeout(180000);

  // ...
}

Planning #7

W trakcie kolejnej sesji planowania wywiązała się żywa dyskusja między Mariuszem a Klientem na temat stabilności strony zamówień. Ustalono wówczas, że zamiast generować raport w czasie rzeczywistym będzie on tworzony w tle.

Podejście #1

@Post('orders/report')
async generateReports(@Body('month') month: number,
  @Body('year') year: number) {

  const report = await this.appService
    .getOrderReport(year, month);

  if (report === null) {
    setTimeout(async () => {
      await this.appService
        .generateOrderReport(year, month);
    });
  }

  return {};
}

Wady

  • Utrata kontroli nad przebiegiem aplikacji
  • Brak gwarancji, że skrypt asynchroniczny się wykona
  • Potencjalne źródło problemów race condition
  • System kolejkowy w Node.js
  • Oparty na Redis
  • Wbudowany system izolacji worker'ów
  • Operacje wykonywane za jego pomocą są atomowe

Krok #1 - inicjalizacja

import { Injectable } from '@nestjs/common';

import * as Bull from 'bull';
import { getRedisConfig } from './config.helpers';

@Injectable()
export class OrderReportService {
  private queue: Bull.Queue;
  private readonly queueName = 'order_reports';

  constructor() {
    this.queue = new Bull(this.queueName, {
      redis: getRedisConfig()
    });
  }
}

Krok #2 - definicja

this.queue.process('generate_report',
  async (job: Bull.Job, done: Bull.DoneCallback) => {
    setTimeout(() => {
      console.log(`Report generated!`);
      console.log(JSON.stringify(job.data));

      done();
    }, 5000);
  }
);

Krok #3 - delegacja

async generate(year: number, month: number) {
  await this.queue.add('generate_report', {
    year,
    month
  });
}

Planning #14

Od wprowadzenia systemu kolejkowania w aplikacji pojawiło się 6 nowych typów raportów i przyszedł czas aby poprawić wydajność systemu ich generowania (stabilność na tym etapie jest zadawalająca).

Bull - cykl życia

​źródło: https://optimalbits.github.io/bull/

  • Producer
  • Consumer
  • Listener

Bull - struktura

Planning #18

Po kolejnych usprawnieniach w systemie generowania raportów Klient poprosił Mariusza o możliwość generowania wybranego raportu cyklicznie.

Definicja

this.paymentsQueue = new Bull(this.queueName, {
  redis: getRedisConfig()
});

// Queue process logic omitted...

this.paymentsQueue.add({}, {
  repeat: { cron: '0 10 * * MON-FRI' }
});

Monitorowanie

  • Taskforce
  • Arena
  • bull-repl
  • Prometheus (z Bull Queue Exporter)

Gotowe aplikacje UI

  • Graficzny interfejs dla Bull i Bee
  • Monitorowanie kolejek i zadań
  • Podgląd szczegółów i stacktrace
  • Możliwość uruchomienia ponownie i wznowienia zadania
  • Wsparcie dla Docker'a

Arena

Konfiguracja

{
  "queues": [{
    "name": "order_reports",
    "hostId": "Order Reports",
    "host": "docker.for.mac.localhost",
    "port": 6379
  }]
}

Uruchomienie

docker run -p 4567:4567 \
  --name bull_arena --rm \
  -v ~/arena/config.json
    :/opt/arena/src/server/config/index.json \
  mixmaxhq/arena:latest

Planning #22

W trakcie kolejnej sesji planowania klient poprosił Mariusza o dodanie do systemu raportowania powiadomień mailowych kiedy dany raport jest już gotowy.

Implementacja

this.queue.on('completed', (async job => {
  await this.sendEmailNotification(
  	job.data.year,
    job.data.month
  );
}));

Lokalne eventy #1

 

.on('error', (error) => {});

.on('waiting', (jobId) => {
  // A Job is waiting to be processed
  // as soon as a worker is idling.
});

.on('active', (job, jobPromise) => {
  // A job has started.
  // You can use jobPromise.cancel()
  // to abort it.
});

.on('stalled', (job) => { });

Lokalne eventy #2

.on('progress', (job, progress) => {
  // A job's progress was updated!
});

.on('completed', (job, result) =>{
  // A job successfully completed with a `result`.
});

.on('failed', (job, err) => {
  // A job failed with reason `err`!
});

Lokalne eventy #3

.on('paused', () => {
  // The queue has been paused.
});

.on('resumed', (job) =>{
  // The queue has been resumed.
})

.on('cleaned', (jobs, type) => {
  // Old jobs have been cleaned
  // from the queue. `jobs` is
  // an array of cleaned jobs,
  // and `type` is the type of
  // jobs cleaned.
});

Lokalne eventy #4

.on('drained', () => {
  // Emitted every time the queue has
  // processed all the waiting jobs
  // (even if there can be some
  // delayed jobs not yet processed)
});

.on('removed', (job) => {
  // A job successfully removed.
});

Globalne eventy

// Local event
queue.on('completed', listener):

// Global event - listens to whole queue,
// distributed across Redis listeners
queue.on('global:completed', listener);

Co dalej?

const myJob = await myqueue.add({
  foo: 'bar'
}, {
  priority: 3
});

Priorytety

Współbieżność

this.queue.process('generate_report', 5,
  async (job: Bull.Job, done: Bull.DoneCallback) => {
    setTimeout(() => {
      console.log(`Report generated!`);
      console.log(JSON.stringify(job.data));

      done();
    }, 5000);
  }
);

Sandbox

Sometimes jobs are more CPU intensive which will could lock the Node event loop for too long and Bull could decide the job has been stalled. To avoid this situation, it is possible to run the process functions in separate Node processes. In this case, the concurrency parameter will decide the maximum number of concurrent processes that are allowed to run.

Thank you!

https://www.dragonia.org.pl

https://www.cloudcorridor.com

Bull - rozproszone zadania w Node.js na poważnie

By Łukasz Rybka

Bull - rozproszone zadania w Node.js na poważnie

meet.js Warszawa #31, Warszawa, 22.08.2019

  • 81

More from Łukasz Rybka