1 of 43

PostgreSQL:�simple tasks queue

fprochazka.cz

2 of 43

Hi, I’m Filip Procházka

  • I’ve worked
    • mainly with PHP, Java, Kotlin, Python
    • in Damejidlo.cz, cogvio.com, shipmonk.com, and others
    • on web-based application, data pipelines, and others
    • as Principal Eng., Head of Development, VP of Eng., …
  • Currently working as Technical Domain Lead of Supply Chain in Rohlik
  • fprochazka.cz

3 of 43

Agenda

  • What? Why?
  • Task queues with traditional approaches
  • Use-cases with database approach
  • Related topics (risks, etc.)

4 of 43

Use-cases for task queues?

  • Patterns
    • Moving a sub-problem from HTTP request to a background job
      • HTTP requests must be quick to scale well
      • Background jobs are usually best limited in concurrency
      • Parts of the business operation might fail and having them as background jobs allows simple and controlled retry
    • Big task is broken down into many smaller tasks
      • Smaller unit of work can be used as retry/fork

5 of 43

Use-cases for task queues?

  • Many small tasks
    • Sending notifications (emails, mobile, SMS, … )
    • Service-to-service communication (event bus, ..)
    • Async API communications with 3rd-parties
    • Sync data from primary database to search database
  • Fewer big tasks
    • (Pre)computing data (reservation slots, …)
    • Big export (+ send result over notification)
    • Data scraping

6 of 43

Problem definition

  • Part 1: telling a worker that there is some work to do
    • Crons - a process is periodically started, looks up task(s) and starts work
    • Consumers - pub/sub pattern - a consumer is pooling messages and starts work immediately
    • Forks - the main process forks itselfs (new thread/process) into a new “background” worker
  • Part 2: storing data about the work done
    • How do you know that it has succeeded?
    • How do you know what is doing how much work?
    • How do you know what is running right now or has run historically?
    • What if the application itself needs to know the status for something?
  • Part 3: making sure only one worker works on a given task
    • Locks, retries, exactly-once-delivery, FIFO, priorities, delays, dead-letter queues, …

7 of 43

Task queues with message brokers

  • Pick a message broker (RabbitMQ, AWS SQS, …)
  • Connect producers & consumers via the broker
  • Push messages
  • Have them processed by a consumer
  • Profit

8 of 43

Message brokers

  • Proven & straightforward technology
  • Independently (auto)scale producers & consumers
  • Quick processing of large message volumes

9 of 43

Total Cost of Ownership (TCO)

Is the technology adding enough benefits to be worth the additional cost?

  • Engineering - Learning, designing, integrating, testing, migrations, …
  • Infra cost - compute, network, storage, …
  • Operations & maintenance - HA, backups, outages, upgrades, monitoring, performance, …
  • Security - access, compliance, …

10 of 43

11 of 43

Message brokers

  • Messaging vs event stream
  • RabbitMQ solves only the part1 & partially part3
    • If you want to persist any data related to messages, you have to do so elsewhere
  • Persistent event streams are solvable using Kafka
    • But to make Kafka’s TCO worth it, you have to change how you design apps
    • Still, you cannot simply query “FROM tasks WHERE id = 1”, it solves a different problem

12 of 43

Problem: distributed commit (or two-phase-commit)

  • What?
    • You have to commit your database transaction �and “commit” your push to the message broker
    • Both must pass or both must rollback.
  • This is where the distributed systems fun begins!
    • Polyglot persistence always increases complexity and TCO

13 of 43

Simple & reasonably safe in small scale

  • Open transaction
  • Do the work
  • $entityManager->flush()
    • Any constraint violations will show up here
  • $rabbitmq->publish()
    • Now that we know database transaction is gonna be OK, we can publish messages
    • Failing to send the message should rollback the transaction
  • $database->commit()
    • Unless you have DEFERRED constraints, this is will “always” pass

⚠️ not bulletproof � data inconsistency may occur, but the risk is usually tolerable for simpler apps

14 of 43

Outbox Pattern

  • Within the PostgreSQL Transaction:
    • Instead of directly publishing to RabbitMQ, write the message to a dedicated outbox table in PostgreSQL.
    • This table should include a status flag or the rows should be deleted upon publishing.
    • Commit the transaction as usual.
  • Outbox Poller:
    • A separate background job reads new messages from the outbox table.
    • It publishes them to RabbitMQ.
    • Upon successful publish, it marks the message as processed or deletes it.

⚠️ not bulletproof � exchanges the "catastrophic data inconsistency" problem for the "at-least-once delivery" problem

15 of 43

Having only one database eliminates the need for the distributed commit�(reducing complexity and TCO)

16 of 43

Problem: locking a resource

  • Tasks usually work on some shared resource�(order, product, inventory, …)
  • We don’t want different resources racing,�and corrupting the data
  • Approaches:
    • A) All changes are piped through a single Q
      • If you have only one consumer, �you can omit locking, �but with multiple you’ll encounter race conditions
    • B) You need to always lock resources

17 of 43

Filesystem lock

  • How
    • Open file stream
    • flock($file)
    • When you’re done, close the stream (file is unlocked)
    • Profit
  • Good
    • If the process dies, the lock is also released �=> there is a live “link” between the worker and the resource
  • Bad
    • Only works on a single machine

18 of 43

Redlock

  • SET <key> <val> NX PX <ttl-ms>
    • Pass means you got the key
    • Fail means its locked
    • Once you’re done with your work, you should release the key by deleting it
  • Good
    • Works in distributed environment
  • Bad
    • If the process dies, you have to wait until the lock expires

19 of 43

TTL lock with a database

CREATE TABLE shedlock(

name TEXT NOT NULL, lock_until TIMESTAMP NOT NULL,

locked_at TIMESTAMP NOT NULL, locked_by TEXT NOT NULL,

PRIMARY KEY (name) );

INSERT INTO shedlock (name, lock_until, locked_at, locked_by) VALUES (...)

ON CONFLICT (name) DO UPDATE

SET lock_until = :lockUntil, locked_at = :now, locked_by = :lockedBy

WHERE shedlock.lock_until <= :now;

-- once you’re done, you should explicitly unlock

  • Good - Works in distributed environment
  • Bad - If the process dies, you have to wait until the lock expires

20 of 43

Advisory lock with a database

SET LOCK_TIMEOUT = '15s'; -- default timeout is 0 => indefinitely

SELECT pg_advisory_xact_lock(10);

-- obtains a shared transaction-level advisory lock, waiting if necessary

SELECT pg_try_advisory_xact_lock(10);

-- either obtain the lock immediately and return true

-- or return false without waiting if the lock cannot be acquired immediately

  • Good
    • Works in distributed environment
    • If the process dies, the lock is also released �=> there is a live “link” between the worker and the resource

21 of 43

Transactional row locking with a database

SET LOCK_TIMEOUT = '15s'; -- default timeout is 0 => indefinitely

SELECT * FROM table FOR UPDATE;

-- wait for a set time and error if row cannot be locked

SELECT * FROM table FOR UPDATE NOWAIT;

-- if row cannot be locked, do not wait and throw error

SELECT * FROM table FOR UPDATE SKIP LOCKED;

-- any selected rows that cannot be immediately locked are skipped

-- behaves intuitively and cooperates with LIMIT

  • Good
    • Works in distributed environment
    • If the process dies, the lock is also released

22 of 43

If you don’t have complex requirements or huge volumes of data, the problem can be solved using just PostgreSQL

(you are not Google, so simpler approaches are probably good enough)

23 of 43

Using PostgreSQL as task-queue

  • You (most likely) already have a database
  • It has various mechanism usable for task-allocations
  • It does not have great mechanisms for instant messaging
    • … but background jobs rarely need to happen instantly
  • Using only a single database discards the distributed commit problem
    • Everything can be committed in a single transaction, increasing reliability
  • All your knowledge and all the database flexibility applies

24 of 43

Use-case: notifications (email, sms, push, …)

  • Requirements:
    • Notification must be sent within +- a minute window
    • We want to know what we’ve sent and to whom
  • High-level workflow:
    • A business operation may schedule notifications
    • Separate CRON process picks them up and sends them periodically

25 of 43

Use-case: notifications

-- schema

CREATE TYPE notification_status AS

ENUM ('pending', 'sent');

CREATE TABLE notification_email (

id UUID NOT NULL,

created_at TIMESTAMP NOT NULL,

status notification_status NOT NULL,

sent_at TIMESTAMP,

PRIMARY KEY (id)

);

26 of 43

Use-case: notifications

-- schedule

INSERT INTO notification_email (id, created_at, status)

VALUES (uuid_generate_v7(), NOW(), 'pending');

-- consume & process

SELECT *

FROM notification_email

WHERE status = 'pending'

LIMIT 100

FOR NO KEY UPDATE SKIP LOCKED;

-- you should explicitly open & close a transaction

-- the lock is released on COMMIT

27 of 43

FOR UPDATE vs FOR NO KEY UPDATE

CREATE TABLE notification_email_something (

id UUID NOT NULL,

notification_id UUID NOT NULL,

PRIMARY KEY (id),

FOREIGN KEY (notification_id) REFERENCES notification_email (id)

);

SELECT * FROM notification_email FOR UPDATE;

-- When postgre validates FK of the notification_email_something�-- it needs to acquire FOR KEY SHARE on the notification_email row,

-- which is blocked by the strong lock acquired by the FOR UPDATE

SELECT * FROM notification_email FOR NO KEY UPDATE;

-- this acquires weaker lock, which does not block FOR KEY SHARE

28 of 43

Easy: naive FIFO

SELECT *

FROM notification_email

WHERE status = 'pending'

ORDER BY created_at ASC

LIMIT 100

FOR NO KEY UPDATE SKIP LOCKED;

29 of 43

Easy: delayed sending

ALTER TABLE notification_email

ADD COLUMN send_after TIMESTAMP;

SELECT *

FROM notification_email

WHERE status = 'pending' AND send_after >= NOW()

ORDER BY created_at ASC

LIMIT 100

FOR NO KEY UPDATE SKIP LOCKED;

30 of 43

Easy: priorities

ALTER TABLE notification_email

ADD COLUMN priority INT DEFAULT 10; -- 0 low, 10 normal, 100 high

SELECT *

FROM notification_email

WHERE status = 'pending' AND send_after >= NOW()

ORDER BY priority DESC, created_at ASC

LIMIT 100

FOR NO KEY UPDATE SKIP LOCKED;

31 of 43

Easy: max attempts

ALTER TABLE notification_email

ADD COLUMN attempts INT DEFAULT 0;

SELECT *

FROM notification_email

WHERE status = 'pending' AND send_after >= NOW() AND attempts < 50

ORDER BY priority DESC, created_at ASC

LIMIT 100

FOR NO KEY UPDATE SKIP LOCKED;

32 of 43

Use-case: notifications

  • Part 1: telling a worker that there is some work to do ✅
    • It’s a cron, infra runs it every 5sec-1min
    • Work is allocated using a flexible SQL
  • Part 2: storing data about the work done ✅
    • It’s all in the database
  • Part 3: making sure only one worker works on a given task ✅
    • Locks ✅, retries ✅, exactly-once-delivery ✅, FIFO ✅, priorities ✅, delays ✅, …
  • Bonus:
    • No need to have a special process to requeue failed/dropped/lost messages

33 of 43

Optimizing task queue tables

  • Partial indices
    • CREATE INDEX idx ON notification_email (status) WHERE status = 'pending';
  • Partitioned tables
    • One partition for 'pending' status
    • One partition other statuses, possibly sub-partitioned by time

34 of 43

Monitoring locks

CREATE EXTENSION pgrowlocks;

-- available also by default on AWS RDS

SELECT *

FROM notification_email AS a,

pgrowlocks('notification_email') AS p

WHERE p.locked_row = a.ctid;

-- simple way to inspect table row locks

SELECT * FROM pg_locks;

-- all locks in the system, but non-trivial to join onto table

35 of 43

Risk: deadlocks

  • There is no way to guarantee they’ll never happen
  • Database can detect deadlocks
    • If it detects them, it kills one of the transactions to allow the other to finish
  • Strategies to minimize risk
    • Always lock resources in the same order (first A, then B)
    • Define “aggregate roots” and only lock those

36 of 43

Risk: DDL

  • PostgreSQL has transactional DDL
    • You can wrap multiple tables schema changes and data updates into a single transaction
  • Locked rows prevent schema modifications
  • Default lock_timeout is infinite
    • Either configure a default for statement_timeout & lock_timeout, or set it in every migration
    • Locks are acquired FIFO => the migration can block the rest of the application
  • Solution: Introduce a mechanism to pause processing
    • You tell the application to stop crunching the tasks => no rows are locked => lock for DDL is acquired instantly
    • E.g. by turning off workers before deploy and re-starting after deploy
    • E.g. by having an explicit feature toggle

37 of 43

Messaging in PostgreSQL - LISTEN/NOTIFY

LISTEN my_channel;

-- start listening within this connection

-- pick up messages using pg_get_notify()

NOTIFY my_channel;

-- pushes to active listeners

-- when nobody is listening, its voided

-- there are no retries or delays

38 of 43

Messaging in PostgreSQL - pgmq extension

SELECT pgmq.create('my_queue');

-- creates the queue

SELECT * FROM pgmq.send(queue_name => 'my_queue', msg => '{"foo": "bar1"}');

-- returns ids

SELECT * FROM pgmq.read(queue_name => 'my_queue', vt => 30, qty => 1);

-- read a msg from a queue and "lock" it for 30 seconds

SELECT * FROM pgmq.pop('my_queue');

-- read and immediately delete a msg

39 of 43

Use-case: exports

  • It’s wasteful to have a heavy cron run every few seconds
    • You have to allocate and pay for the HW, even if no tasks are scheduled
  • The task queue is similar to notifications, but instead of cron we did Forks
    • We’ve used the AWS API to start a new AWS ECS task for the worker
      • Once task is done, the worker terminates
      • You can start an expensive machine with lots of cpu/ram/disk
  • Good alternative would be AWS Lambda,�if you know the task will finish within ~15mins

40 of 43

Use-case: dynamic crons

  • Requirements:
    • There are dynamically scheduled tasks
    • There is dynamic delay between them
      • They must run, and there must be reasonable delay between runs
      • If they fail, they must run next appropriate opportunity
    • In contrast with
      • A cron that runs once a day and fails will not attempt to run for another day

41 of 43

Use-case: dynamic crons

CREATE TABLE task (id UUID NOT NULL, name TEXT NOT NULL, PRIMARY KEY (id));

CREATE TABLE task_run_scheduled (

id UUID NOT NULL,

task_id UUID NOT NULL,

run_after TIMESTAMP NOT NULL,

status TEXT NOT NULL, -- enum

PRIMARY KEY (id),

FOREIGN KEY (task_id) REFERENCES task (id)

);

-- insert into task_run with next time

-- query every few seconds for tasks to run

-- before commit, insert row with next run

42 of 43

Questions?

43 of 43

fprochazka.cz