a curated list of database news from authoritative sources

November 04, 2026

Patroni, Ubuntu 24.04, and two unplanned PostgreSQL failovers

I usually run PostgreSQL on managed services, like Azure Database for PostgreSQL Flexible Server but for once I was running PostgreSQL/Patroni on VMs. An overnight data-loading operation had been running for eight and a half hours when its PostgreSQL connections were lost. The client-side symptoms were:

SSL connection has been closed unexpectedly

An SSL error is a symptom, not a diagnosis. PostgreSQL had not run out of memory or disk. The server was being stopped by service management. Neither PostgreSQL nor Patroni needed a package upgrade, but an unattended zlib shared-library update caused the issue.

Ubuntu 24.04's needrestart automatically restarted affected services, including my custom Patroni service. The primary stopped, another node was promoted, and the load failed. Then the new primary received its own unattended updates and stopped too, causing a second failover.

What was running

This was a self-managed PostgreSQL 17/Patroni lab on Azure:

  • Three PostgreSQL nodes: pg1, pg2 and pg3, across three availability zones.
  • Three separate etcd nodes for Patroni's distributed configuration store.
  • One application client VM.
  • Canonical Ubuntu 24.04 LTS guests.

Hostnames, service prefixes, and local policy filenames below are consistently anonymized. Timestamps, package versions, and database messages are unchanged.

The deployment record identifies the image as Canonical:ubuntu-24_04-lts:server:latest, resolved to 24.04.202610210, with Azure patchMode=ImageDefault and assessmentMode=ImageDefault. Those settings did not disable maintenance already configured inside Ubuntu.

Root cause analysis

I correlated PostgreSQL, Patroni, and systemd messages from the same journal. PostgreSQL's stderr was captured under the Patroni unit here. A patroni[PID] journal prefix does not mean every message was generated by Patroni itself: the PostgreSQL LOG and FATAL messages identify the database side.

On pg1:

sudo journalctl --utc --no-pager -o short-iso \
  --since '2026-11-04 06:18:50 UTC' \
  --until '2026-11-04 06:20:20 UTC' \
  -u franckpachot-lab-patroni.service |
  grep -E 'Stopping |Stopped |Started |shutdown request|administrator command'

Selected captured lines, with repeated connection-termination messages omitted:

2026-11-04T06:19:04+00:00 franckpachot-lab-pg1-kc-20261103 patroni[46600]: 2026-11-04 06:19:04 UTC-6aea534b.b608-LOG:  received smart shutdown request
2026-11-04T06:19:04+00:00 franckpachot-lab-pg1-kc-20261103 patroni[46623]: 2026-11-04 06:19:04 UTC-6aea534c.b61f-FATAL:  terminating connection due to administrator command
2026-11-04T06:19:04+00:00 franckpachot-lab-pg1-kc-20261103 systemd[1]: Stopping franckpachot-lab-patroni.service - franckpachot-lab PostgreSQL Patroni quorum lab...
2026-11-04T06:19:06+00:00 franckpachot-lab-pg1-kc-20261103 patroni[46600]: 2026-11-04 06:19:06 UTC-6aea534b.b608-LOG:  received fast shutdown request
2026-11-04T06:20:14+00:00 franckpachot-lab-pg1-kc-20261103 systemd[1]: Stopped franckpachot-lab-patroni.service - franckpachot-lab PostgreSQL Patroni quorum lab.
2026-11-04T06:20:14+00:00 franckpachot-lab-pg1-kc-20261103 systemd[1]: Started franckpachot-lab-patroni.service - franckpachot-lab PostgreSQL Patroni quorum lab.

This is positive evidence of an administered shutdown and a service restart, not merely an inference from lost connections. "Administrator command" does not identify a human: automated service management can produce it.

There is a service-unit detail worth checking as well:

systemctl show franckpachot-lab-patroni.service \
  -p KillMode -p KillSignal

The follow-up inspection returned:

KillMode=control-group
KillSignal=15

My custom unit had no explicit KillMode setting. With control-group, systemd's stop signal is not restricted to the Patroni parent process. Service-unit shutdown semantics matter too. I should not assume that restarting the supervisor leaves PostgreSQL untouched.

Prove that unattended upgrades actually ran

The familiar status command was a useful starting point:

systemctl status unattended-upgrades.service --no-pager

Before remediation, it showed:

Loaded: loaded (/usr/lib/systemd/system/unattended-upgrades.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-11-03 16:25:04 UTC; 15h ago
Main PID: 1641 (unattended-upgr)
/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal

This does not prove packages were being installed at that moment. That particular process waits to coordinate shutdown with unattended upgrades. The decisive evidence comes from the scheduled service and transaction logs.

On pg1:

sudo journalctl --utc --no-pager -o short-iso \
  --since '2026-11-04 06:10:00 UTC' \
  --until '2026-11-04 06:25:00 UTC' \
  -u apt-daily-upgrade.service

Selected captured lines:

2026-11-04T06:18:58+00:00 franckpachot-lab-pg1-kc-20261103 systemd[1]: Starting apt-daily-upgrade.service - Daily apt upgrade and clean activities...
2026-11-04T06:20:31+00:00 franckpachot-lab-pg1-kc-20261103 systemd[1]: Finished apt-daily-upgrade.service - Daily apt upgrade and clean activities.

The unattended-upgrades log supplies the package list:

sudo grep -E '^2026-11-04 06:.*(Starting unattended|Packages that will be upgraded|All upgrades installed)' \
  /var/log/unattended-upgrades/unattended-upgrades.log

The first transaction's relevant lines:

2026-11-04 06:18:58,776 INFO Starting unattended upgrades script
2026-11-04 06:19:02,465 INFO Packages that will be upgraded: bind9-dnsutils bind9-host bind9-libs libbrotli1 libmspack0t64 libnss3 libpcre2-8-0 zlib1g
2026-11-04 06:20:30,481 INFO All upgrades installed

This was automated package maintenance in the exact incident window.

What package changed, and when?

The ordinary dpkg database log gives timestamped package actions:

sudo grep -E '^2026-11-04 06:19:.*(upgrade zlib1g|configure zlib1g)' \
  /var/log/dpkg.log

Captured output:

2026-11-04 06:19:03 upgrade zlib1g:amd64 1:1.3.dfsg-3.1ubuntu2.2 1:1.3.dfsg-3.1ubuntu2.3
2026-11-04 06:19:03 configure zlib1g:amd64 1:1.3.dfsg-3.1ubuntu2.3 <none>

This proves dpkg performed the package upgrade/configuration. It is not a process-audit record of dpkg's full command-line arguments.

Where did the systemctl restart command come from?

Not from /var/log/dpkg.log. The literal command appears in the captured package-manager output:

sudo sed -n \
  '/^Log started: 2026-11-04  *06:19:02/,/^Log ended:/p' \
  /var/log/unattended-upgrades/unattended-upgrades-dpkg.log

Selected lines from that transaction:

Log started: 2026-11-04  06:19:02
Preparing to unpack .../zlib1g_1%3a1.3.dfsg-3.1ubuntu2.3_amd64.deb ...
Setting up zlib1g:amd64 (1:1.3.dfsg-3.1ubuntu2.3) ...
Restarting services...
 systemctl restart azuremonitor-coreagent.service franckpachot-lab-host-metrics.service franckpachot-lab-patroni.service fwupd.service gcd.service polkit.service rsyslog.service ssh.service udisks2.service walinuxagent.service
Log ended: 2026-11-04  06:20:15

The custom Patroni service is explicitly named.

These logs also print commands under a deferred-restarts heading elsewhere. A bare grep for systemctl restart is not sufficient: inspect the heading and surrounding transaction. Here the Patroni command is under Restarting services..., and systemd independently records that exact
service stopping and starting.

The output does not timestamp each printed command. I assign 06:19:04 to the observed Patroni stop/PostgreSQL shutdown from the journal.

Together, the transaction output and systemd journal establish the restart path. I did not have an auditd/execve trace recording every parent PID and argument, so this is log correlation, not a reconstructed process audit.

Why would a zlib upgrade restart Patroni?

The installed APT hook answers that question:

cat /etc/apt/apt.conf.d/99needrestart

Its active configuration line was:

DPkg::Post-Invoke {"test -x /usr/lib/needrestart/apt-pinvoke && /usr/lib/needrestart/apt-pinvoke -m u || true"; };

The invoked script ultimately executes:

    exec /usr/sbin/needrestart "$@"

needrestart checks running processes for outdated shared libraries. A service can therefore be selected without its own package being upgraded. It is not simply walking package dependencies.

The explanation was already installed on the VM:

cat /usr/share/doc/needrestart/README.Ubuntu

The first paragraph:

Starting from Ubuntu 24.04, needrestart in the default configuration will by default not show the Debconf prompt, and restart automatically the affected services. This includes when run as part of `unattended-upgrades` to ensure that the security updates it brings are actually applied to running services
rather than leaving the system vulnerable.

This is a documented Ubuntu 24.04 behavior, not a claim about every Ubuntu release or every customized Azure image. Canonical describes the change and its security rationale in
needrestart changes in Ubuntu 24.04: service restarts. While this may be great for services that are resilient to restarts, it's probably not a good idea for a monolithic database such as PostgreSQL, where restarting the primary interrupts all client sessions.

Updating a library on disk does not update the copy already mapped into a running process. Restarting affected services applies the fix. The missing piece in my setup was coordinating that legitimate maintenance with HA and a long-running load.

But why would Patroni have zlib loaded?

A direct import zlib in Patroni's own source is not required. Patroni runs inside Python, and imported modules or native dependencies can load shared libraries transitively. Python's zlib extension is one possible route: modules such as gzip and zipfile can use it. A native dependency of another
component in the service is another possibility.

However, being a Python process does not automatically mean that zlib is loaded at startup. On typical distribution builds, the Python zlib module is a dynamically loaded extension, not evidence that the interpreter itself unconditionally links and loads libz. The actual imports and native dependencies matter.

needrestart examines running processes, including their memory mappings, to identify obsolete library files and associate affected processes with services. It does not need to prove that application code is actively calling the library. A process can keep an old library mapped after an upgrade replaces the on-disk file.

There is another distinction: the Patroni systemd service also contains PostgreSQL processes. Selecting that unit does not, by itself, prove that the Patroni Python parent, rather than a process it manages, held the obsolete mapping.

For a running service, investigate with:

pid=$(systemctl show franckpachot-lab-patroni.service -p MainPID --value)
sudo grep -E 'libz\.so|deleted' "/proc/$pid/maps"
systemctl status franckpachot-lab-patroni.service --no-pager

Inspect the mappings of the other processes in the service as well. Use ldd on the relevant executable or native extension to investigate library dependencies, remembering that ldd alone does not list every library loaded later at runtime. These are diagnostic commands, not captured historical output: after a restart, the old mappings are gone.

The retained evidence establishes: zlib upgrade -> immediate restart list containing Patroni -> observed service shutdown -> failover. It does not include a pre-restart memory-map snapshot identifying the precise importing module or process, so I would not present a particular Python dependency chain as proven.

Nor would I explain it as zlib enabling compressed PostgreSQL connections: PostgreSQL's standard client/server protocol does not provide general-purpose compression of that kind. Any proposed libpq dependency path needs evidence from the installed build, not that assumption.

What about other releases and distributions?

There are three separate policy questions:

  1. Are updates checked or downloaded automatically?
  2. Are packages actually installed automatically?
  3. Which running services are restarted afterward, and by whom?

A timer being enabled does not answer all three. Neither does an
Automatic-Reboot setting.

Ubuntu before and after 24.04

Canonical's description of the 24.04 change explicitly contrasts it with the previous needrestart behavior:

  • Before 24.04, the APT-integrated tool prompted for service restarts in interactive use. In noninteractive use, it listed affected services without restarting them. This is the pre-Noble behavior relevant when comparing older releases such as 20.04 and 22.04, where the tool is installed and configured that way.
  • Ubuntu 24.04 changed that APT-hook behavior to restart affected services directly in both contexts, including unattended upgrades. That is the behavior documented and observed on these VMs.
  • Other or later releases and customized images need their own checks. An upgraded host can retain configuration overrides, and a cloud image can add automation. Do not infer the effective policy from the release name alone.

The older behavior was not a promise that package upgrades could never restart a service: package maintainer scripts and other automation are separate paths. An older release also doesn't mean unattended security updates were disabled. The relevant change here is what happens to affected running processes after an APT transaction.

On an Ubuntu or Debian host, inspect rather than guess:

cat /etc/os-release
dpkg-query -W unattended-upgrades needrestart
apt-config dump | grep -E 'APT::Periodic|Unattended-Upgrade::Automatic-Reboot'
systemctl list-timers --all 'apt-*' --no-pager
cat /etc/apt/apt.conf.d/99needrestart
grep -R -n 'nrconf.*restart' /etc/needrestart

Debian, RHEL and Fedora

These are documented policy differences:

System Automatic package installation What to check for service disruption
Debian, including 12/13 deployments unattended-upgrades is controlled by APT periodic configuration and installer/image choices. Do not assume every installation enables it. Inspect the installed needrestart version, mode, hooks, and package scripts. Sharing APT does not establish that Ubuntu's specific -m u policy applies.
RHEL 9 Red Hat documents installing dnf-automatic and enabling a timer. By default, it downloads and reports updates without installing them. The selected timer matters: dnf-automatic-install.timer explicitly installs updates and overrides the corresponding configuration options. Inspect package scripts and any external maintenance tooling too.
Fedora 41 and newer, traditional DNF-based systems Fedora documents that the automatic-update component is not installed by default; after installation, its default policy downloads without installing. The documented setup uses dnf5-plugin-automatic and an explicitly enabled timer. Check /etc/dnf/automatic.conf, the timer, and the restart/reboot policy. Desktop offline updates and image-based/Atomic systems are different update paths.

Sources:

For the documented DNF-based setups, useful inspection commands include:

systemctl list-timers --all 'dnf*' --no-pager
grep -E '^[[:space:]]*(apply_updates|download_updates|reboot)[[:space:]]*=' \
  /etc/dnf/automatic.conf

Defaults are often bad for critical systems such as monolithic databases. None of these host-local tools automatically knows which PostgreSQL node is the current leader or whether taking down another replica is safe. The HA maintenance policy still has to be designed for the full stack.

Every VM had its own maintenance window

The effective configuration enabled daily unattended upgrades:

cat /etc/apt/apt.conf.d/20auto-upgrades
apt-config dump | grep -E 'APT::Periodic::(Update-Package-Lists|Unattended-Upgrade)'

The configuration file contained:

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

The packaged timer definition, captured on all seven hosts, contained:

cat /usr/lib/systemd/system/apt-daily-upgrade.timer

[Unit]
Description=Daily apt upgrade and clean activities
After=apt-daily.timer

[Timer]
OnCalendar=*-*-* 6:00
RandomizedDelaySec=60m
Persistent=true

[Install]
WantedBy=timers.target

The guests used UTC. Each machine independently scheduled maintenance around 06:00, with up to a 60-minute randomized delay. Persistent=true also permits catch-up after a missed timer activation. This is not a cluster-aware rolling
maintenance policy.

I checked the unattended-upgrades logs and dpkg logs on all seven VMs:

VM Unattended upgrades started, UTC zlib upgrade, UTC Observed effect on the owned workload service
etcd2 06:09:38.423 06:09:48 No etcd restart recorded; process start remained November 3
pg1 06:18:58.776 06:19:03 Patroni stop at 06:19:04; first primary lost
pg3 06:19:28.899 06:19:44 Patroni stop at 06:19:45; standby also taken down
etcd1 06:24:02.614 06:24:09 No etcd restart recorded; process start remained November 3
client 06:26:39.054 06:26:44 Host-metrics service in restart list; load had already failed
etcd3 06:35:02.180 06:35:20 No etcd restart recorded; process start remained November 3
pg2 06:38:01.956 06:38:16 Patroni stop at 06:38:17; replacement primary lost

All seven listed the same set of packages for upgrade. Every PostgreSQL node's dpkg-output log explicitly included franckpachot-lab-patroni.service in the immediate service-restart list.

"Around the same time" means the same maintenance hour, not simultaneous restarts. pg1 and pg3 stopped only 41 seconds apart. pg2 stopped 19 minutes and 13 seconds after pg1, by which point it had become the primary.

Random jitter spread the jobs out. It did not prevent maintenance from overlapping recovery or targeting the new leader.

Patroni and PostgreSQL logs show two failovers

Run this on each PostgreSQL VM:

sudo journalctl --utc --no-pager -o short-iso \
  --since '2026-11-04 06:18:50 UTC' \
  --until '2026-11-04 06:45:00 UTC' \
  -u franckpachot-lab-patroni.service |
  grep -E 'Stopping |Stopped |Started |promoted self|selected new timeline'

On pg2, the first promotion is explicit:

2026-11-04T06:19:29+00:00 franckpachot-lab-pg2-kc-20261103 patroni[47953]: 2026-11-04 06:19:29,047 INFO: promoted self to leader by acquiring session lock
2026-11-04T06:19:29+00:00 franckpachot-lab-pg2-kc-20261103 patroni[48105]: 2026-11-04 06:19:29 UTC-6aea538b.bbe9-LOG:  selected new timeline ID: 23

pg3 was then stopped by its own maintenance:

2026-11-04T06:19:45+00:00 franckpachot-lab-pg3-kc-20261103 systemd[1]: Stopping franckpachot-lab-patroni.service - franckpachot-lab PostgreSQL Patroni quorum lab...
2026-11-04T06:21:15+00:00 franckpachot-lab-pg3-kc-20261103 systemd[1]: Started franckpachot-lab-patroni.service - franckpachot-lab PostgreSQL Patroni quorum lab.

Later, maintenance caught up with pg2, now the primary:

2026-11-04T06:38:17+00:00 franckpachot-lab-pg2-kc-20261103 systemd[1]: Stopping franckpachot-lab-patroni.service - franckpachot-lab PostgreSQL Patroni quorum lab...

Two seconds later, pg3 promoted:

2026-11-04T06:38:19+00:00 franckpachot-lab-pg3-kc-20261103 patroni[101738]: 2026-11-04 06:38:19,889 INFO: promoted self to leader by acquiring session lock
2026-11-04T06:38:19+00:00 franckpachot-lab-pg3-kc-20261103 patroni[101843]: 2026-11-04 06:38:19 UTC-6aeacedd.18dd3-LOG:  selected new timeline ID: 24

The resulting leadership sequence was:

pg1 / timeline 22
  -> pg2 / timeline 23 at 06:19:29
  -> pg3 / timeline 24 at 06:38:19

Patroni uses the DCS leader lock to coordinate leadership. Stopping a leader can release the lock or expire the lease, allowing an eligible replica to acquire leadership. These logs prove the promotions and new timelines. They do not by themselves establish whether a particular lock was explicitly deleted or expired after ttl, as that would require additional
lease/key history.

For planned PostgreSQL restarts, Patroni provides patronictl restart and the REST /restart endpoint. Restarting the Patroni systemd service is not the same as a restart. A controlled maintenance plan may also require a deliberate switchover, replica checks, and application reconnection handling. HA does not make existing client sessions survive a primary shutdown.

Why "automatic reboot disabled" did not protect me

I initially looked at:

grep Reboot /etc/apt/apt.conf.d/50unattended-upgrades

It showed:

//Unattended-Upgrade::Automatic-Reboot "false";
// when Unattended-Upgrade::Automatic-Reboot is set to true
//Unattended-Upgrade::Automatic-Reboot-WithUsers "true";
//Unattended-Upgrade::Automatic-Reboot-Time "02:00";

Those are commented examples/default documentation, not active assignments. Use apt-config dump to inspect effective overrides rather than treating a comment as the final configuration.

More importantly, a service restart is not a VM reboot. That option controls unattended-upgrades' automatic reboot behavior, not needrestart's service-restart behavior.

To check the distinction:

uptime -s
journalctl --list-boots --no-pager
last -x reboot | head

The captured pg1 boot time remained:

2026-11-03 16:24:54

The PostgreSQL VMs' boot times all preceded the incident. The observed 06:19 event was a service shutdown/restart, not a fresh guest boot.

Durability is not availability

Even with healthy replicas, sufficient retained/archived WAL, and successful node rejoin, these unplanned failovers would still be an availability incident. Recovering the data does not recover the application sessions that were connected to the stopped primary.

It helps to separate three questions:

  • Durability / RPO: which acknowledged transactions survive?
  • Availability / RTO: when can the application successfully execute work again?
  • Session and transaction continuity: what happens to connections, in-flight transactions, and long-running operations?

WAL archiving is important for recoverability, but it does not preserve TCP connections or, by itself, guarantee zero data loss. The failover RPO depends on replication, commit settings, and which replica can safely be promoted. The point here is not a claim about lost committed data: even a zero-data-loss
failover can interrupt every session connected to the old primary.

A stable endpoint, virtual IP, or load balancer can direct new connections to the new leader. It cannot move an existing PostgreSQL session, its transaction, and its session state to another server. A proxy may keep a frontend socket open, but that is not a general guarantee that the backend transaction survives.

The logs show about 25 seconds between pg1's shutdown request and pg2's promotion, and about two seconds between pg2's shutdown and pg3's promotion. Those are server event intervals, not measured application downtime. Endpoint health checks, connection timeouts, pool recovery, retries, and transaction replay can extend the interruption.

For an application whose database sessions are terminated, that interruption is downtime, no matter how quickly a dashboard turns green again.

Resilience includes controlling avoidable failures

Three availability zones protect against some infrastructure failures. They do not isolate nodes from the same package update and the same host-local restart policy.

This was a correlated operational failure: the same maintenance mechanism ran independently on every VM, with no cluster-level gate. Random delay is not coordination. The second restart did not know that its target had just become the replacement primary.

Automatic failover remains valuable for actual failures. But it is a recovery mechanism, not permission to turn routine maintenance into repeated unplanned outages. The objective should be to avoid unnecessary failovers, not merely celebrate that promotion worked twice.

For resilience, I want both sides of the contract:

  1. Cluster-aware maintenance. Patch replicas in a controlled sequence, verify health and catch-up before proceeding, preserve enough capacity for the configured synchronous-replication policy, and coordinate any leader transition. Apply equivalent quorum-aware sequencing to etcd. One host at a time is a starting point, not proof that every quorum configuration can tolerate that host being unavailable.
  2. Application-aware recovery. Exercise connection-pool recovery and bounded retries with backoff. Retry the right transaction or resumable unit of work, not an arbitrary fragment of a transaction. Handle uncertain commit outcomes with idempotency or reconciliation rather than assuming that a lost connection means nothing committed.
  3. Application-level measurement. Test successful requests, error rates, latency, and time to resume useful work, not just leader election. Include maintenance scenarios where a replica is already unavailable.
  4. An explicit disruption budget. Define how many nodes may be unavailable and which checks must pass before continuing maintenance. Abort the rollout if recovery is incomplete rather than letting another timer fire.

A planned switchover can still disconnect clients. "Planned" does not mean "invisible." It makes the timing, sequencing, application handling, and expected impact deliberate. That is materially different from two independent package jobs unexpectedly stopping successive primaries.

What I changed to avoid the issue happening again

For this lab, I chose explicit maintenance windows rather
than independent automatic updates on seven machines.

First, I checked for active package transactions. I did not kill a running dpkg operation. Then I stopped, disabled, and persistently masked the named automatic-update units on all seven VMs:

sudo systemctl disable --now \
  apt-daily.timer apt-daily-upgrade.timer unattended-upgrades.service
sudo systemctl mask \
  apt-daily.timer apt-daily-upgrade.timer unattended-upgrades.service

I also set needrestart to list-only mode, so a later normal APT transaction would report affected services rather than automatically restarting them:

printf '%s\n' '$nrconf{restart} = "l";' |
  sudo tee /etc/needrestart/conf.d/zz-franckpachot-lab-list-only.conf

The earlier targeted exclusions for the owned database, DCS, and supporting services remain in place. The broader list-only setting was an explicit subsequent decision, not the initial policy.

The rollout verified each host's effective configuration, not just the existence of a new file. Every host reported:

VERIFIED: effective needrestart restart mode=l (list only)
apt-daily.timer enabled=masked active=inactive
apt-daily-upgrade.timer enabled=masked active=inactive
unattended-upgrades.service enabled=masked active=inactive

To inspect unit state:

systemctl show apt-daily.timer apt-daily-upgrade.timer unattended-upgrades.service \
  -p Id -p UnitFileState -p ActiveState

The effective needrestart configuration was verified by loading its main Perl configuration, which includes the sorted drop-ins, and checking that $nrconf{restart} resolves to l. On the installed version, an explicit restart-mode setting is honored by the APT hook's -m u mode.

I also persisted the policy in provisioning and added a pre-operation check across all seven VMs. It refuses to proceed if maintenance protection cannot be verified. Fixing running machines without fixing their replacement images would only postpone the same surprise.

This is not a recommendation to abandon security updates. It moves the responsibility to an explicit maintenance procedure:

  1. Agree on a maintenance window and the acceptable application interruption.
  2. Verify replication, DCS health, and recoverability.
  3. Patch in a controlled sequence, coordinating service restarts and any necessary switchover through Patroni.
  4. Verify the cluster and apply the restarts needed to use updated libraries.
  5. Reverify maintenance protection and application readiness afterward.

The masks block these named systemd activation paths. List-only mode blocks needrestart's default automatic restart path. Neither is a universal barrier against explicit mode overrides, package maintainer scripts, manual restarts, VM reboots, or separately configured Azure patch orchestration.

The lesson

The failure domain was not just a VM, a disk, or an availability zone. It was also the same maintenance policy installed independently on every node.

Ubuntu was applying security fixes as designed. Patroni performed failovers when primaries stopped. The missing piece was coordination: routine maintenance should have been a planned operation, not a sequence of independently triggered primary failures.

Durable data is essential, but it is not the whole HA contract. If every connection to the primary is dropped and applications must recover their work, availability has been affected. Resilience means reducing that impact, preventing avoidable disruptions, and electing a new leader.

A database service spans layers that make decisions independently. In this case, Ubuntu restarted a service after a package upgrade, and Patroni responded to the resulting primary shutdown with a failover. Each did what it was configured to do, but the disruption stemmed from the lack of coordination between them. Kubernetes-based deployments can encounter similar interactions between controllers, as I explored in an earlier CloudNativePG transient-failure test. A managed service may use some of the same components, but the key difference is whether maintenance, failover, and client recovery are designed and tested together—not whether one vendor manages the components.

November 03, 2026

General Availability of Percona Search for MongoDB

Percona Search for MongoDB 1.70.5-3 is now Generally Available and supported for production use. You can now easily run full-text, vector, and hybrid searches with automatic embedding on production Percona Server for MongoDB deployments. The release also adds the Hugging Face Inference API as a new automatic embedding provider. In this blog post, I cover why … Continued

The post General Availability of Percona Search for MongoDB appeared first on Percona.

November 02, 2026

PostgreSQL document compression: TOAST thresholds, storage policies, and page size

A common way to store documents in PostgreSQL is a table with an ID and a JSONB column. The open-source DocumentDB extension instead stores BSON, the binary document format used by MongoDB, to provide more data types and MongoDB compatibility.

Both approaches commonly combine two patterns that do not perfectly fit what SQL databases were designed for:

  • Rows with a single variable-length column, whose values can range from a few dozen bytes to many megabytes.
  • Repeated data or metadata, such as similar field names and arrays of objects with the same schema, like time-series buckets.

MongoDB's storage engine, WiredTiger, packs many documents into B-tree leaf pages. The default maximum page sizes — 32 KB for leaf pages and 4 KB for internal pages — are limits on the uncompressed page image fed to the block compressor, not on individual documents: MongoDB configures leaf_value_max=64MB, so a document larger than 32 KB simply produces a larger leaf page rather than overflow storage. Snappy compresses each page image for ordinary collections (time-series collections use Zstd, which ignores the global compressor setting), and the compressed result is written in multiples of the 4 KB allocation_size. Because compression runs over the whole page image, several small documents — including their repeated field names — contribute redundant byte sequences to the same compression input.

A JSON or BSON document can contain substantial byte-sequence redundancy: repeated field names, common name prefixes, and repeated values or structures. LZ77-family compressors such as Snappy and LZ4 can replace matching sequences with backreferences to earlier bytes. They don't need to understand JSON, and matches aren't restricted to the beginning of a document.

Binary does not mean compressed: BSON includes field names, and repeated embedded objects repeat those names. JSONB can contain similar redundancy. PostgreSQL can compress those sequences in JSON, JSONB, or a BSON extension type. However, the decision to attempt compression is not driven by document redundancy. It belongs to TOAST's generic heap-row storage policy: reduce a wide row, trying column compression before storing permitted attributes externally in chunks.

This distinction matters for small documents. It also makes increasing the page size potentially counterproductive: the same document can stop being compressed on a larger-page build.

I will reproduce both effects, then compare the SQL controls with the hardcoded values. The examples use PostgreSQL 19 and core JSONB, so they can run without an extension or a custom patch. The distinction between whole-row activation and individual-column compression also applies to the TOAST-backed BSON values stored by DocumentDB.

What is compressed, and what is measured?

TOAST measures the whole tuple (row) to decide whether to start. However, compression acts on individual eligible column values, not on the entire row or all the documents in a page.

PostgreSQL's ordinary table storage uses fixed-size pages, 8 KB by default. A row cannot span those pages. Large column values can instead be moved to an associated TOAST table, and the main row then contains a pointer to access the chunks in the TOAST table by index scan.

JSONB still contains compressible byte sequences. It is a binary representation, not an already-compressed JSON string. Within one object, it normalizes key order and duplicate keys, but an array of similarly structured objects can repeat the same field names. No shared key dictionary exists across those objects or across independently stored JSONB rows.

The distinction between repeated names within a document and repeated names across documents is important. PostgreSQL compresses one column value at a time. PostgreSQL calls each value a datum in its source code. That per-datum compression can use the former repetition, not the latter.

MongoDB's WiredTiger storage engine uses Snappy block compression on disk by default for ordinary collections. Small documents can share a page, so one block-compression input can include repeated names and structures from several documents. This is not a separate compression call per small document. Other codecs can be configured, and time-series collections have different defaults. This is a difference in compression scope, not a capability reserved to Snappy.

This post measures stored datum representations, not table allocation or query performance. I use:

  • pg_column_size(doc::text::jsonb) to reconstruct an uncompressed logical JSONB datum for these ordinary JSON inputs
  • pg_column_compression(doc) to report the actual accepted method
  • pg_column_size(doc) for its stored representation
  • pg_column_toast_chunk_id(doc) IS NOT NULL to identify external values

The last function is available in PostgreSQL 19. For an external value, pg_column_size is not the inline pointer size. The on-disk pointer in the main row occupies 18 bytes. The external size excludes the outer four-byte varlena length word, which explains some four-byte differences below without attributing them to the codec.

Reproduce the environment and output

All SQL needed for the experiments is included below. Run the statements in order in a fresh test database. For each page-size comparison, start a separate PostgreSQL instance.

I use aligned ASCII output, with NULL displayed as (null). To reproduce that format, start an existing server's session with these options:

psql -X -P pager=off -P 'null=(null)' -P format=aligned \
  -P linestyle=ascii -P border=1 -U postgres -d postgres

These are the initial settings on the source-built 8 KB server:

postgres=# SHOW server_version;

 server_version 
----------------
 19rc1
(1 row)

postgres=# SHOW block_size;

 block_size 
------------
 8192
(1 row)

postgres=# SHOW default_toast_compression;

 default_toast_compression 
---------------------------
 lz4
(1 row)

The selected codec is explicit in every test. PostgreSQL 14-18's upstream default is pglz, but an LZ4-enabled PostgreSQL 19 RC1 build defaults to LZ4. SET COMPRESSION has been available since PostgreSQL 14.

LZ4 is a sensible starting choice when compression/decompression CPU matters, but the workload still determines the trade-off. It does not always produce the smallest result. DocumentDB's current setup/tuning configuration selects default_toast_compression = 'lz4' when supported. This is a launcher-chosen PostgreSQL setting, not a property of BSON, nor a guarantee that every installation is configured that way. Check the live server and column settings rather than assuming them from a product name.

A compressible document below the activation threshold

I construct a JSONB array containing eight objects with repeated temperature and humidity names. Both rows receive the same document. The only difference is an unrelated column stored PLAIN, so it cannot be compressed or moved externally.

postgres=# CREATE TABLE small_documents(
    id integer, doc jsonb, other text
);

CREATE TABLE

postgres=# ALTER TABLE small_documents
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE small_documents
            ALTER COLUMN other SET STORAGE PLAIN
;

ALTER TABLE

postgres=# INSERT INTO small_documents
    SELECT id,
           jsonb_build_object('samples', (
             SELECT jsonb_agg(jsonb_build_object('temperature',20,'humidity',60))
             FROM generate_series(1,8)
           )),
           repeat('q', CASE id WHEN 1 THEN 0 ELSE 1600 END)
    FROM generate_series(1,2) AS g(id)
;

INSERT 0 2

postgres=# SELECT id,
           pg_column_size(doc::text::jsonb) AS logical_bytes,
           octet_length(other) AS other_bytes,
           pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes,
           pg_column_toast_chunk_id(doc) IS NOT NULL AS external
    FROM small_documents ORDER BY id
;

 id | logical_bytes | other_bytes | compression | stored_bytes | external 
----+---------------+-------------+-------------+--------------+----------
  1 |           508 |           0 | (null)      |          508 | f
  2 |           508 |        1600 | lz4         |          109 | f
(2 rows)

The first 508-byte JSONB datum remains uncompressed. The second compresses to 109 bytes, even though its uncompressed document bytes are identical. The additional 1,600-byte column made the tuple large enough to enter TOAST processing.

This proves both points:

  1. Repeated field names and values in JSONB are compressible.
  2. PostgreSQL does not independently ask whether that small JSONB datum would benefit from compression. The enclosing tuple controls activation.

I used an unrelated column for academic purposes. Of course, I'm not suggesting that adding more data to trigger compression is a good idea.

The hardcoded gate is not the per-table target

In the PostgreSQL source, the ordinary values are derived as follows:

#define TOAST_TUPLES_PER_PAGE 4
#define TOAST_TUPLE_THRESHOLD MaximumBytesPerTuple(TOAST_TUPLES_PER_PAGE)
#define TOAST_TUPLE_TARGET TOAST_TUPLE_THRESHOLD

MaximumBytesPerTuple(4) accounts for page headers, line pointers, and alignment. It is not exactly block_size / 4. Here are the values for the default 8 KB page size and for PostgreSQL builds with 16 KB or 32 KB pages:

PostgreSQL page size Fixed tuple activation threshold Default tuple target Maximum native TOAST chunk payload
8,192 bytes 2,032 bytes 2,032 bytes 1,996 bytes
16,384 bytes 4,080 bytes 4,080 bytes 4,044 bytes
32,768 bytes 8,176 bytes 8,176 bytes 8,140 bytes

These are the values for the tested eight-byte-aligned builds. The chunk capacities were also observed from actual external data, not inferred from page size alone.

Both heap_prepare_insert and heap_update check the fixed threshold before invoking the toaster for fresh inline tuples. Inside the toaster, toast_tuple_target provides the reduction target, after subtracting tuple header overhead.

It’s not simply "compress when the row cannot fit on a page.” A tuple just over 2 KB can still fit on an 8 KB page. By default, wide rows are reduced earlier, with the goal of keeping their size consistent with roughly four tuples per page. This is a row-density and oversized-attribute policy, rather than a separately tunable document-compression policy.

These are fresh values with one eligible document column. If a row has several eligible columns, the toaster works in phases rather than compressing the entire row as one object.

Change the target without changing activation

The following input view constructs fresh JSONB values from repetitive text. It deliberately samples several payload sizes. It is not intended to represent a natural document distribution.

A view is used rather than copying stored compressed values from another table. Reusing an existing compressed datum would confound a test of whether the destination attempts compression.

postgres=# CREATE TEMP VIEW input_documents AS
    SELECT n, jsonb_build_object('v', repeat('x', 1024 * (1 << n))) AS doc
    FROM (VALUES (0),(1),(2),(6),(9),(10),(11)) AS sizes(n)
;

CREATE VIEW

postgres=# CREATE TABLE extended_default(
    n integer PRIMARY KEY, doc jsonb
);

CREATE TABLE

postgres=# CREATE TABLE extended_200(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=200)
;

CREATE TABLE

postgres=# CREATE TABLE extended_4k(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=4096)
;

CREATE TABLE

postgres=# ALTER TABLE extended_default
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE extended_200
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE extended_4k
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# INSERT INTO extended_default
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# INSERT INTO extended_200
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# INSERT INTO extended_4k
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# SELECT policy, 1 << n AS payload_kib,
           pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes,
           pg_column_toast_chunk_id(doc) IS NOT NULL AS external
    FROM (
      SELECT 1 AS position, 'default' AS policy, * FROM extended_default
      UNION ALL SELECT 2,'target_200',* FROM extended_200
      UNION ALL SELECT 3,'target_4096',* FROM extended_4k
    ) AS cases
    ORDER BY position,n
;

   policy    | payload_kib | compression | stored_bytes | external 
-------------+-------------+-------------+--------------+----------
 default     |           1 | (null)      |         1041 | f
 default     |           2 | lz4         |           39 | f
 default     |           4 | lz4         |           47 | f
 default     |          64 | lz4         |          288 | f
 default     |         512 | lz4         |         2083 | t
 default     |        1024 | lz4         |         4139 | t
 default     |        2048 | lz4         |         8252 | t
 target_200  |           1 | (null)      |         1041 | f
 target_200  |           2 | lz4         |           39 | f
 target_200  |           4 | lz4         |           47 | f
 target_200  |          64 | lz4         |          284 | t
 target_200  |         512 | lz4         |         2083 | t
 target_200  |        1024 | lz4         |         4139 | t
 target_200  |        2048 | lz4         |         8252 | t
 target_4096 |           1 | (null)      |         1041 | f
 target_4096 |           2 | (null)      |         2065 | f
 target_4096 |           4 | lz4         |           47 | f
 target_4096 |          64 | lz4         |          288 | f
 target_4096 |         512 | lz4         |         2087 | f
 target_4096 |        1024 | lz4         |         4139 | t
 target_4096 |        2048 | lz4         |         8252 | t
(21 rows)

The default JSONB storage strategy is EXTENDED, which permits both compression and external storage.

Three observations distinguish the decisions:

  • Even with a 200-byte target, the 1 KB-payload document still remains uncompressed and inline at 1,041 bytes. Its tuple did not enter TOAST because it is smaller than the hardcoded MaximumBytesPerTuple(4).
  • Once TOAST is active, lowering the target externalizes values sooner: the 64 KB payload is inline with the default target but external with the 200-byte target.
  • With a 4 KB target, the 2 KB-payload document remains uncompressed at 2,065 bytes. Its tuple enters TOAST, but already meets the larger target. The same document compressed to 39 bytes in the other two tables.

Raising the target is therefore not an independent "retain more compressed documents inline" control. It can also prevent a compression attempt.

The default and 200-byte cases report 288 and 284 bytes respectively for the same compressed 64 KB-payload document. This is the inline/external varlena accounting difference, not a better compression result.

Storage strategies: what SQL can control

There are four column storage strategies:

Strategy Compression External storage
EXTENDED Permitted, attempted in the first phase when reduction is needed Permitted
EXTERNAL Not permitted Permitted
MAIN Permitted, attempted in a later phase when needed Last resort
PLAIN Not permitted Not permitted

MAIN does not guarantee inline storage. MAIN attributes are also skipped during the first compression pass: they get their own compression round only after EXTENDED/EXTERNAL attributes have been compressed and externalized. They are likewise skipped by the out-of-line loops until the last-resort pass, which uses TOAST_TUPLE_TARGET_MAIN (MaximumBytesPerTuple(1) ≈ 8,160 bytes on an 8 KB build). EXTERNAL does not mean "externalize every document,” and an explicit LZ4 method cannot override its prohibition on compression.

Here EXTERNAL and MAIN use the same 4 KB target, making the comparison independent of an accidental target difference:

postgres=# CREATE TABLE external_4k(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=4096)
;

CREATE TABLE

postgres=# CREATE TABLE main_4k(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=4096)
;

CREATE TABLE

postgres=# CREATE TABLE plain_case(
    n integer PRIMARY KEY, doc jsonb
);

CREATE TABLE

postgres=# ALTER TABLE external_4k
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE main_4k
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE plain_case
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE external_4k
            ALTER COLUMN doc SET STORAGE EXTERNAL
;

ALTER TABLE

postgres=# ALTER TABLE main_4k
            ALTER COLUMN doc SET STORAGE MAIN
;

ALTER TABLE

postgres=# ALTER TABLE plain_case
            ALTER COLUMN doc SET STORAGE PLAIN
;

ALTER TABLE

postgres=# INSERT INTO external_4k
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# INSERT INTO main_4k
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# INSERT INTO plain_case
    SELECT * FROM input_documents WHERE n<=2 ORDER BY n
;

INSERT 0 3

postgres=# SELECT policy, 1 << n AS payload_kib,
           pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes,
           pg_column_toast_chunk_id(doc) IS NOT NULL AS external
    FROM (
      SELECT 1 AS position,'EXTERNAL' AS policy,* FROM external_4k
      UNION ALL SELECT 2,'MAIN',* FROM main_4k
      UNION ALL SELECT 3,'PLAIN',* FROM plain_case
    ) AS cases
    ORDER BY position,n
;

  policy  | payload_kib | compression | stored_bytes | external 
----------+-------------+-------------+--------------+----------
 EXTERNAL |           1 | (null)      |         1041 | f
 EXTERNAL |           2 | (null)      |         2065 | f
 EXTERNAL |           4 | (null)      |         4109 | t
 EXTERNAL |          64 | (null)      |        65549 | t
 EXTERNAL |         512 | (null)      |       524301 | t
 EXTERNAL |        1024 | (null)      |      1048589 | t
 EXTERNAL |        2048 | (null)      |      2097165 | t
 MAIN     |           1 | (null)      |         1041 | f
 MAIN     |           2 | (null)      |         2065 | f
 MAIN     |           4 | lz4         |           47 | f
 MAIN     |          64 | lz4         |          288 | f
 MAIN     |         512 | lz4         |         2087 | f
 MAIN     |        1024 | lz4         |         4143 | f
 MAIN     |        2048 | lz4         |         8252 | t
 PLAIN    |           1 | (null)      |         1041 | f
 PLAIN    |           2 | (null)      |         2065 | f
 PLAIN    |           4 | (null)      |         4113 | f
(17 rows)

For EXTERNAL, the 2 KB payload is still inline, but the 4 KB payload is external without compression.

For MAIN, the 1 MB payload compresses to 4,143 inline bytes. It can stay inline because MAIN postpones externalization to its last-resort target: the maximum tuple size that fits on one page. The larger 2 MB-payload document still becomes external after compression.

For PLAIN, no value compresses or becomes external. A row that cannot fit on the heap page fails. Run this separate statement expecting an error:

postgres=# INSERT INTO plain_case
    VALUES (3,jsonb_build_object('v',repeat('x',8192)))
;

ERROR:  54000: row is too big: size 8240, maximum size 8160
LOCATION:  RelationGetBufferForTuple, hio.c:531

The 8,160-byte maximum in this error is the heap-page tuple limit, not the 2,032-byte TOAST activation threshold. They answer different questions.

This is the complete SQL control surface relevant to these cases:

Control Scope Changes the fixed activation threshold?
ALTER COLUMN ... SET STORAGE ... Column's permitted/preferred representation No
ALTER COLUMN ... SET COMPRESSION pglz/lz4 Codec when compression is attempted No
default_toast_compression Default codec when the column has no override No
ALTER TABLE ... SET (toast_tuple_target=...) Whole-tuple reduction target after entry No
TOAST_TUPLE_THRESHOLD, TOAST_TUPLES_PER_PAGE Compiled activation policy Source change
TOAST_MAX_CHUNK_SIZE, EXTERN_TUPLES_PER_PAGE Compiled external chunk capacity Separate from activation
BLCKSZ Compiled page size Changes several defaults together

Changing page and chunk sizes requires a freshly initialized cluster. They are not runtime GUCs. Changing only a main-tuple threshold is not inherently an on-disk page-format change, but a correct implementation still has to consider all heap entry paths and TOAST-table creation.

The compression algorithm still matters after activation

Compression is an attempt, not a storage guarantee. Pglz's default strategy has a 32-byte minimum input, a 25% minimum reduction policy, and a 1,024-byte encoded-output first-success limit: it aborts if it has emitted that much without finding a match.

Those are pglz strategy rules, not user-configurable document thresholds. LZ4 does not impose pglz's first-success or minimum-rate rules. Both must still pass TOAST's header/alignment savings check.

Changing the column codec does not rewrite existing values:

postgres=# CREATE TABLE codec_example(
    id integer PRIMARY KEY, doc jsonb
);

CREATE TABLE

postgres=# ALTER TABLE codec_example
            ALTER COLUMN doc SET COMPRESSION pglz
;

ALTER TABLE

postgres=# INSERT INTO codec_example
    VALUES
      (1,jsonb_build_object('v',repeat('x',4096)))
;

INSERT 0 1

postgres=# ALTER TABLE codec_example
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# INSERT INTO codec_example
    VALUES
      (2,jsonb_build_object('v',repeat('x',4096)))
;

INSERT 0 1

postgres=# SELECT id, pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes
    FROM codec_example ORDER BY id
;

 id | compression | stored_bytes 
----+-------------+--------------
  1 | pglz        |           71
  2 | lz4         |           47
(2 rows)

The old value remains pglz. The freshly constructed new value uses LZ4. An update that leaves the document unchanged, or a copy of an already compressed datum, need not recompress it. Therefore, changing the setting is not proof that existing data now uses the new codec.

Larger pages: fewer documents become compression candidates

Larger pages can reduce the number of external chunks. That does not mean they preserve compression behavior.

I rebuilt PostgreSQL 19 RC1 with --with-blocksize=8, 16, and 32, keeping the other configure options the same, then initialized fresh clusters.

Do not reuse a data directory across page sizes. A different page size also requires rebuilding linked extensions against that PostgreSQL installation. This comparison deliberately uses core JSONB rather than mixing extension versions or a stock image with a different source-built baseline.

8 KB: all but the smallest repeated-field document compress

The samples array now contains 8, 64, 128, or 256 similarly structured objects. All tests explicitly select LZ4. One table retains the build's default target. The other sets it to 2040.

postgres=# CREATE TEMP VIEW page_inputs AS
    SELECT n, jsonb_build_object
                                    
                                    
                                    
                                    
                                

An alternative to sharding a MongoDB replica set that outgrew its memory: a proof of concept

An alternative to sharding a MongoDB replica set that outgrew its memory: a proof of concept A replica set that no longer fits in memory Most MongoDB replica sets start small. The working set fits in the WiredTiger cache, reads come from memory, and nobody thinks about disks. Then the data keeps growing. Orders, events … Continued

The post An alternative to sharding a MongoDB replica set that outgrew its memory: a proof of concept appeared first on Percona.

AWS contributes a large-transaction binary log optimization to MySQL

When MySQL commits a large transaction, writing it to the binary log can freeze every other commit on the server for minutes. This post introduces Binary Log Optimization for Large Transactions (BOLT), an AWS contribution to community MySQL that makes commit latency independent of transaction size, speeds crash recovery, and improves throughput.

How VillageSQL does open source

VillageSQL plans and builds VillageSQL Server, the open source MySQL fork that adds an extension framework, in public, and every contributor follows the same written rules.

November 01, 2026

Multi-Node Unused Index Verification

Every MySQL schema that’s been around for a few years collects indexes nobody remembers adding. Some were for a report that was retired. Some came from an ORM migration. A few were added “just in case” and never used. They all cost something: extra writes on every INSERT and UPDATE, increased buffer pool pressure, larger … Continued

The post Multi-Node Unused Index Verification appeared first on Percona.

October 30, 2026

What’s New in PSMDB Sandbox: Six Months of Updates

Earlier this year, we introduced PSMDB Sandbox, a lightweight web UI that sits on top of our Terraform and Ansible automation for MongoDB. Since then, the project has changed a lot. It has a new name and repository, and it supports many more Percona products, MongoDB features and platforms. In this post, I will go … Continued

The post What’s New in PSMDB Sandbox: Six Months of Updates appeared first on Percona.

October 29, 2026

Village News: MySQL News + Events (29 October 2026)

Welcome back to Village News, our curated roundup of MySQL and database news. This week, plans for FOSDEM 2027 took shape, with a new M-Ecosystem devroom and MySQL Belgian Days opening their calls for papers.

If you want to get these updates, just subscribe to the blog.

Enjoy!

MySQL

Benchmarking Vector search in six database engines

We loaded 990,000 OpenAI embeddings of 1,536 dimensions into six databases that index vectors themselves, built an HNSW index in each, and measured five things: time to a queryable index, storage and memory, recall against throughput, throughput under concurrent clients, and what happens when 10% of the corpus is rewritten. Same host, same container shape, … Continued

The post Benchmarking Vector search in six database engines appeared first on Percona.

Turns out database backups are NOT trivial

I thought backing up a database was simple, you just loop over the tables and copy the rows out. Nipunn sat me down at a whiteboard and showed me why that can give you a backup of data that never existed, and how Convex gets around it.

PLGM Has Grown Up: A New UI and Many More Features

Back in December I introduced Percona Load Generator for MongoDB Clusters (PLGM), a benchmark tool built around one idea: test your database with your own schema and your own queries, not some random data. At the time, PLGM was a command-line tool. You edited a YAML file, ran the binary, and read the numbers in … Continued

The post PLGM Has Grown Up: A New UI and Many More Features appeared first on Percona.

Percona Operator for MySQL 1.3.0: autopilot tuning, zero-data-loss async failover, encrypted PITR

  Percona Operator for MySQL 1.3.0 is out. This release moves three things forward for teams running MySQL on Kubernetes: the operator now tunes MySQL for you based on the pod resources, async clusters can fail over without losing data in flight, and point-in-time recovery works on clusters where binary logs are encrypted at rest. … Continued

The post Percona Operator for MySQL 1.3.0: autopilot tuning, zero-data-loss async failover, encrypted PITR appeared first on Percona.

October 28, 2026

Why you should stop using ARBITERS in MongoDB

This blog post was born of a discussion with a colleague in Percona Support, who also co-authored it with me here on the use of the ARBITER feature in MongoDB installations nowadays. Turns out, after analyzing past real cases we’ve faced in support, maybe it’s time to say goodbye to ARBITERS. We are not saying … Continued

The post Why you should stop using ARBITERS in MongoDB appeared first on Percona.

How to Extract Data from InnoDB, MyISAM, and MyRocks Files Without a Running Server

Probably everyone responsible for managing database servers at some point realizes that it would be helpful to have a tool capable of extracting data directly from raw database files. Something completely offline, without the need to have a running database server, and that would work with data directories, files, or binary backups (storage volume snapshots, … Continued

The post How to Extract Data from InnoDB, MyISAM, and MyRocks Files Without a Running Server appeared first on Percona.