Is the gateway backup consuming too much memory?

Hi,

I don’t know if I’m the only one, but I regularly get OOM errors when I allocate 8Gb to Gladys. This causes Gladys to restart every night.

After days of debugging without understanding the cause, I used Claude to move forward and identify a better hypothesis. And in the following days the problem was identified: the way the database is backed up.

Context: I keep all states indefinitely, as long as I have disk space.

Am I the only one this happens to?


Bug: the backup Gateway can cause an OOM-kill of the Node process

Components affected:
server/lib/gateway/gateway.backup.js
server/models/index.js (function duckDbCreateBackupInstance)

The problem: duckDbCreateBackupInstance() opens a new separate DuckDB instance on the same .duckdb file as the main instance (already active in read/write):

const backupDatabase = await DuckDBInstance.create(duckDbFilePath, {
  memory_limit: duckDbMemoryLimit,
  access_mode: 'READ_ONLY',
});

However, the official documentation of the Node.js « Neo » client for DuckDB explicitly states:

« Multiple instances in the same process should not attach the same database. »

The mechanism intended to avoid this is DuckDBInstanceCache.getOrCreateInstance(path), which reuses the same instance (and thus the same buffer pool / memory_limit) for the same file. The current code directly calls DuckDBInstance.create(), which creates two independent buffer pools, each capped at the same DUCKDB_MEMORY_LIMIT.

Result: the worst-case memory usage of DuckDB alone becomes 2 × memory_limit instead of 1 × memory_limit.

Worsening: the DuckDB documentation also specifies that memory_limit « only applies to the buffer manager » — compression buffers (GZIP compression used in EXPORT DATABASE) can consume in addition to this limit.

Proof in production (2 identical crashes, logs included):

  • RSS stable ~2.5Gb before backup (including ~1.2Gb of DuckDB BASE_TABLE)
  • Jump to ~4.6Gb in less than a minute, right after the log « Backing up DuckDB into a Parquet folder »
  • Process killed (ExitCode=137) before reaching the log « Closing DuckDB backup instance to release memory » in the finally — so the crash occurs during the EXPORT DATABASE … FORMAT PARQUET COMPRESSION GZIP call itself, not after.
  • Reproduced identically for 2 days in a row, always at the time of the daily Gateway backup.

What is requested:

  1. Make sure that duckDbCreateBackupInstance() uses DuckDBInstanceCache.getOrCreateInstance() instead of DuckDBInstance.create(), so that the backup shares the buffer pool of the main instance rather than opening a second one.
  2. Consider replacing COMPRESSION GZIP with ZSTD or SNAPPY in the EXPORT DATABASE (less compression buffers, faster export).
  3. Possibly document that DUCKDB_MEMORY_LIMIT should be sized anticipating that two instances may coexist during a backup (so recommend a value ≤ 25% of the container’s allocated RAM, not 30-50%), as long as point 1 is not fixed.

Context to situate the impact: installation with ~8Gb allocated to the container, BASE_TABLE of about 1.1-1.2Gb of sensor history — so not an extreme case, this behavior can probably affect other installations with a significant history.

Well, it turns out I’ve had the same issue for 7 days!
However, I have to restart my LXC Docker with Gladys every morning because nothing responds anymore.

I’m working with Claude, who is supposed to create a watchdog and an automatic restart after a crash, with log recovery just before the OOM because after that, I can’t access anything anymore.

I’ll keep you updated.

Thanks for the feedback, I’ll check it out!

For the first time this morning, it didn’t crash, no OOM:




And I haven’t set up the script yet :frowning:

Since I modified my Docker Compose to limit DuckDB to a maximum of 2GB instead of 4GB, no more crashes (at least not last night). This confirms that I’m on the right track.

Mine was already limited to 2GB since the first OOM issues.
When it started again a week ago, I even limited it to 1.5GB but without success.

version: '3'

services:
  gladys:
    image: gladysassistant/gladys:v4
    container_name: gladys
    restart: always
    privileged: true
    network_mode: host
    cgroup: host
    logging:
      driver: "json-file"
      options:
        max-size: 10m
    environment:
      NODE_ENV: production
      SQLITE_FILE_PATH: /var/lib/gladysassistant/gladys-production.db
      SERVER_PORT: 8420
      TZ: Europe/Paris
      DUCKDB_MEMORY_LIMIT: 1500MB # memory usage limitation for duckDB

Normally @pierre-gilles has already set up an automatic limitation of xx% for duckDB (I don’t remember the exact figure), which works very well except for installations with proxmox where the RAM value of the LXC is not correctly retrieved by docker, and in this case it is indeed necessary to limit (unless LXC RAM = host RAM).

That’s exactly it, LXC with 8GiB, Docker seeing the server’s 64GiB, and OOM.

This is clearly not a « mainstream » issue, but it has already happened several times, I see!

Hi @lmilcent,

Indeed, there are 3 distinct phenomena here :smiley:

1. The separate instance for backup is intentional. We do open a 100% separate DuckDB instance to generate the backup, and yes, this allows DuckDB to temporarily use 2× the authorized limit during the backup. We do this to isolate the backup from production and especially to be able to release the RAM at the end: closing the instance returns all its memory to the OS. If we reused the production instance, we would certainly stay under ×2 during the backup, but the export would fill the production buffer pool to 100% of the limit without ever releasing it, for DuckDB it’s cache, it keeps it forever. Now keeping the entire database in cache because of a 10-minute backup per night is not great ^^

2. In your case, the real problem comes from LXC. The RAM limit of the container is not visible from the inside: Node calculates the 30% on the 64 GB of the server, i.e. ~19 GB of DuckDB limit… way too much for an 8 GB container, and ×2 during the backup. In the meantime, you can force an appropriate limit in your container’s config, for example:

DUCKDB_MEMORY_LIMIT=2GB

(~25% of the 8 GB allocated, leaving room for the backup peak.)

3. Why only now? You probably hadn’t seen the problem in the previous months because your database was smaller. With the accumulation of states, the export uses more and more RAM, until it triggers the OOM.

To improve the automatic detection of the memory limit, I have some leads (reading the cgroup limit via process.constrainedMemory() from Node), but as far as I remember we had done quite a few unsuccessful tests on LXC…

To re-test again, could you run these 3 commands and post the result?

What Node sees from the Gladys container:

docker exec gladys node -e "const os=require('os'); \
console.log('os.totalmem               =', Math.round(os.totalmem()/1048576), 'MB'); \
console.log('process.constrainedMemory =', Math.round((process.constrainedMemory()||0)/1048576), 'MB'); \
console.log('process.availableMemory   =', process.availableMemory ? Math.round(process.availableMemory()/1048576)+' MB' : 'n/a')"

The cgroup limits seen from the container:

docker exec gladys sh -c 'echo "cgroup v2 : $(cat /sys/fs/cgroup/memory.max 2> /dev/null || echo absent)"; \
echo "cgroup v1 : $(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2> /dev/null || echo absent)"; \
echo "meminfo   : $(head -1 /proc/meminfo)"'

The limit that Gladys actually applied:

docker logs gladys 2>&1 | grep -i "memory_limit"

If process.constrainedMemory returns your 8 GB (or if memory.max / cgroup v1 shows the real limit), we can automatically correct the calculation for everyone. If it returns 0 or the host’s RAM, we’ll rather go for more documentation on DUCKDB_MEMORY_LIMIT.

Hi @pierre-gilles, I’m taking the liberty of sharing my test results with you:

root@gladys:~# docker exec gladys node -e "const os=require('os'); \
console.log('os.totalmem               =', Math.round(os.totalmem()/1048576), 'MB'); \
console.log('process.constrainedMemory =', Math.round((process.constrainedMemory()||0)/1048576), 'MB'); \
console.log('process.availableMemory   =', process.availableMemory ? Math.round(process.availableMemory()/1048576)+' MB' : 'n/a')"
os.totalmem               = 15748 MB
process.constrainedMemory = 17592186044416 MB
process.availableMemory   = 6574 MB
root@gladys:~# docker exec gladys sh -c 'echo "cgroup v2 : $(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo absent)"; \
echo "cgroup v1 : $(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo absent)"; \
echo "meminfo   : $(head -1 /proc/meminfo)"'
cgroup v2 : max
cgroup v1 : absent
meminfo   : MemTotal:       16125760 kB
root@gladys:~# docker logs gladys 2>&1 | grep -i "memory_limit"
root@gladys:~# 

Of course, this is what is seen from the LXC, not from the Gladys Docker container.
My LXC has 6GB of RAM, my host has 16GB.

so I hadn’t thought of that for my problems that have come back :roll_eyes:

My duckdb base is almost 6GB :flushed_face:
And is it normal to have the (1) and (2) files? (I admit I don’t remember what I did on 22/06 …)

root@gladys:/var/lib/gladysassistant# ls -al
total 6612704
drwxr-xr-x 15 root root       4096 Jul 29 16:53  .
drwxr-xr-x 22 root root       4096 Jul 14 23:57  ..
drwxr-xr-x  3 root root       4096 Jul 29 02:55  backups
-rw-r-----  1 root root   12009472 Jun 22 11:36 'gladys-production(1).db'
-rw-r-----  1 root root  706248704 Jun 22 11:36 'gladys-production(1).duckdb'
-rw-r-----  1 root root   12009472 Jun 22 15:25 'gladys-production(2).db'
-rw-r-----  1 root root   12009472 Jul 29 22:08  gladys-production.db
-rw-r-----  1 root root      32768 Jul 29 22:08  gladys-production.db-shm
-rw-r-----  1 root root    4120032 Jul 29 22:08  gladys-production.db-wal
-rw-r-----  1 root root 5994196992 Jul 29 16:53  gladys-production.duckdb
-rw-r--r--  1 root root    9957883 Jun 22 11:36 'gladys-production.duckdb(1).wal'
-rw-r--r--  1 root root    5194409 Jun 22 15:25 'gladys-production.duckdb(2).wal'
-rw-r--r--  1 root root   15536313 Jul 29 22:08  gladys-production.duckdb.wal
drwxr-xr-x  2 root root       4096 May 11  2025  homekit
drwxr-xr-x  2 root root       4096 Jun 22 11:36 'homekit(1)'
drwxr-xr-x  2 root root       4096 Jun 22 15:25 'homekit(2)'
drwxr-xr-x  3 root root       4096 May 11  2025  matter
drwxr-xr-x  3 root root       4096 Jun 22 11:35 'matter(1)'
drwxr-xr-x  3 root root       4096 Jun 22 15:25 'matter(2)'
drwxr-xr-x  2 root root       4096 May 11  2025  mosquitto
drwxr-xr-x  2 root root       4096 Jun 22 11:36 'mosquitto(1)'
drwxr-xr-x  2 root root       4096 Jun 22 15:25 'mosquitto(2)'
drwxr-xr-x  5 1000 1000       4096 Feb 21 20:28  node-red
drwxr-xr-x  5 root root       4096 Jun 22 11:37 'node-red(1)'
drwxr-xr-x  5 root root       4096 Jun 22 15:25 'node-red(2)'

Well, I’ve set up a watchdog and log capture for Claude and I’ll see how it goes over the next few days.
At the most, I’ll go back from 1.5GB to 2GB as before by the end of the week.

@lmilcent, if you want the scripts and the conversation Claude had with me to set it up on your Proxmox, I can provide them without any issues, you’ll just need to adjust the paths.

Hi @mutmut,

Thanks for these feedbacks, that’s exactly what we needed! Your results confirm the diagnosis, and unfortunately they also show that we won’t be able to automatically detect the limit in your setup:

  • process.constrainedMemory = 17592186044416: that’s exactly 2⁴⁴ bytes = 16 TiB, the « no limit » value. Consistent with your cgroup v2 at max: your Docker container has no proper memory limit, and the 6 GiB of your LXC are defined in a parent cgroup, invisible from inside the container.
  • MemTotal = 16 GiB: the /proc/meminfo seen by Gladys shows your host, not your LXC. That’s the Docker-in-LXC trap: lxcfs does virtualize the LXC’s /proc, but Docker mounts its own procfs directly from the kernel, bypassing lxcfs.
  • process.availableMemory = 6574 MiB: misleading, it looks like your 6 GiB of LXC, but it’s actually the available memory of your host at that moment, which happens to be close to 6 GiB by coincidence. Unusable for a reliable calculation.

Concretely, in your case Gladys was calculating its DuckDB limit on the 16 GiB of the host: 30 % ≈ 4.7 GiB, and up to ×2 during the backup ≈ 9.4 GiB potential… in a 6 GiB container. The OOM was guaranteed as soon as your database grew.

So: keep your DUCKDB_MEMORY_LIMIT defined explicitly, it’s the only reliable solution in Docker-in-LXC, and we’ll document it clearly for Proxmox/LXC users.

Two points anyway:

  1. You say you still crash with a 1.5 GiB limit: that’s interesting, as it suggests that part of the export’s memory escapes the limit (DuckDB’s memory_limit governs its buffer pool, but the Parquet writer’s and GZIP compression buffers are partially uncounted). Your watchdog logs just before a crash would help us a lot to confirm that, I’m interested!
  2. Your docker logs | grep memory_limit empty is weird: Gladys logs DuckDB initialized with memory_limit=... at every startup. Your logs have probably rotated, you can check again just after a container restart?

On the fix side, here’s what we’re preparing:

  • Strongly limit the backup’s memory itself: the temporary DuckDB instance of the backup will have its own limit, much smaller (~1 GiB max instead of the full limit). The export is a sequential scan, it doesn’t need a big cache. The peak will go from « 2× the limit » to « limit + ~1 GiB », and that protects everyone, even when detection is impossible.
  • Switch the GZIP export to ZSTD: less memory, better compression.
  • Better calculate the default limit: take into account the cgroup limit when it’s visible (Docker with --memory), and absolute ceiling for large hosts. It can’t help your LXC case, but it will prevent the problem for many others.

And for your duplicated files (1) and (2) from June 22: these are probably remnants of manual copies you might have made? Check their size with an ls -lh in the folder. If they are indeed dated copies, you can delete them to recover disk space (keeping the production DBs of course, and their .wal, and their -shm).

Thanks @pierre-gilles for all this info!

After 2 days without crashing, this morning I had an issue.

However, the scripts set up with Claude allowed detecting the leak, saving the logs (I’ll send you that in PM) and restarting the Gladys docker, so it’s definitely on this part.

Claude made me a PR proposal with all the feedback listed in my previous message :slight_smile:

Concretely:

  • Switch from GZIP to ZSTD
  • Limit backup memory to a maximum of 1GB, no need for more cache
  • Better calculate the default limit

Thanks for replying faster than me, I’m still on vacation far from home, I didn’t have much time :upside_down_face:

Thanks for the patches!

I’m definitely up for testing your LXC stuff! @mutmut we’re keeping our fingers crossed that this fixes your issue!