All notable changes to pgraft will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Correctness and security release, with one breaking change: every SQL object
moves into the pgraft schema. Upgrading is
ALTER EXTENSION pgraft UPDATE TO '2.0.0' followed by a restart (the C and Go
libraries must be reloaded together), but callers have to be updated too.
- Every function, view and table now lives in the
pgraftschema, without the redundantpgraft_prefix:pgraft_get_cluster_status()ispgraft.get_cluster_status(),pgraft_add_node()ispgraft.add_node(), and so on for all 34 functions and 19 views. Nothing is created inpublicany more. Argument lists and result columns are unchanged, so the rename is the only edit a caller needs. - The
pgraft_kv_statusview ispgraft.kv_store_status, becausepgraft.kv_statuswas already taken by the etcd-style summary view. - The upgrade script drops the old unqualified objects, views before the
functions they select from, and without
CASCADE- so an object of yours that depends on a pgraft function stops the upgrade instead of disappearing with it.
- Only the first entry of the Raft log was ever written to disk.
saveToDisk()passedmaxSize = 0toMemoryStorage.Entries(), and etcd'slimitSize()returns exactly one entry for that; every persisted log therefore held a single entry with a commit index far past it, and a restart recovered a truncated log. - The Raft state file was never fsynced. HardState (term and vote) and log entries were published with a plain write plus rename, so a crash could lose state that Raft had already been told was durable - losing committed writes and allowing two leaders in one term. The temp file and its directory are now both synced before the rename is considered done.
- Persistence failures were swallowed.
Append()andSetHardState()logged the error and returned success, andprocessReady()went on to send messages and callAdvance(). Both now return the error, andprocessReady()stops before advancing so the same Ready is retried. loadFromDisk()could rewind the commit index. A snapshot with no trailing log entries hit a "no entries, so reset Commit to 0" rule, un-committing committed data. Commit is now only ever clamped down to a genuinely higher bound, and the snapshot is restored before HardState is validated.- The Raft log was never compacted. Nothing on the live path called
CreateSnapshot()orCompact(), so the log grew without bound - and since the whole log is rewritten on every append, each write got progressively more expensive.pgraft.snapshot_count(previously read and then ignored) now drives snapshot-and-compact from the Ready loop, recording the realConfStateso a restart can recover the voter set. Config.Appliedwas never set, so every restart redelivered the entire committed history to the state machine and re-applied it. It is now set from the durable applied index, except when no snapshot exists to recover the configuration from - in that case the log is deliberately replayed, because the voter set lives only in those entries.
- The Go to C apply callbacks were not exported. The module is linked with
-fvisibility=hidden, sopgraft_enqueue_for_apply_from_gowas absent from the module's symbol table; the Go library binds it lazily via-undefined dynamic_lookup, resolved it to address 0, and segfaulted the background worker on the first committed entry. Both callbacks are nowPGDLLEXPORT. - The Go Raft goroutine called PostgreSQL internals directly. The apply
callback ran
ShmemInitStruct()(LWLocks,MyProc), took a spinlock, and calledelog()- none of which is safe off the main thread, andelog(ERROR)siglongjmps into another thread's exception stack. The apply queue is now a lock-free single-producer/single-consumer ring; the producer path takes no locks, allocates nothing, and logs nothing, reporting dropped entries through counters the worker surfaces instead. - Worker shared memory was never allocated at startup.
pgraft_worker_init_shared_memory()was an empty stub, so the command and apply queues were created lazily by whichever process touched them first, with no lock: a second process could observefound == trueand use the spinlock beforeSpinLockInit()had run. All five segments are now allocated in the shmem startup hook underAddinShmemInitLock, and the getters are pure attach. pgraft_go_remove_peer()dereferenced a nilraftNodeand had norecover(), so a panic propagated through cgo and killed the worker process.pgraft_go_tick()cached its resolved symbol in a function static that unload/reload left dangling, and a single failed lookup disabled ticking for the life of the process.
- Every SQL function was executable by PUBLIC, PostgreSQL's default for a new
function. Any logged-in role could rewrite cluster membership with
pgraft.remove_node(), push entries into the replicated log withpgraft.replicate_entry(), or wipe the key/value store. Mutating functions are now revoked from PUBLIC; read-only introspection is granted back explicitly. - SQL injection in the replicated log-entry parser.
pgraft_parse_kv_json_entry()interpolated the replicated key and value straight intoSELECT pgraft.kv_put_local('%s', '%s'), which then ran throughSPI_execute()- so a key containing a quote executed arbitrary SQL on every node in the cluster. Both are now passed throughquote_literal_cstr(). - Peer frame lengths were unbounded. The 4-byte length prefix from an
unauthenticated peer socket was fed straight to
make([]byte, msgLen), allowing a single frame to request up to 4 GiB. Frames are now capped at 64 MiB and an oversized announcement drops the connection.
pgraft_core_update_nodes()zeroed every node's port and leader flag. It rebuilt the array from the id/address pairs the Go layer reports and ran every ~500 ms, so ports registered bypgraft_core_add_node()were discarded. Ports are now parsed out of the reportedhost:portand unreported fields are carried over.pgraft_parse_log_entry()mis-parsed entries.strtok_rcollapses runs of delimiters, so an empty database or schema shifted every later field, and parsing stopped at the first|inside the SQL text.pgraft_remove_completed_commands()corrupted the status ring: it compacted survivors to the head and then also advanced the head past them.pgraft_serialize_log_entry()returnedsnprintf's would-be length, so a truncated entry reported a length past the end of its buffer.- Recoverable failures used
elog(ERROR)inside the background worker, which unwound the apply and command loops the caller was trying to protect, leftresult = -1lines unreachable, and leaked json-c objects allocated off the palloc heap. - Peer writes had no deadline.
sendMessage()is called synchronously from the single Ready consumer, so one peer that vanished without closing its socket stalled all Raft progress indefinitely. - The apply path now refuses SQL entries explicitly instead of crashing: the
worker holds
BGWORKER_SHMEM_ACCESSonly, soGetTransactionSnapshot()andSPI_connect()cannot legally run in it.
- 26 GUCs were registered without the
pgraft.prefix (cert_file,metrics,cors,auto_compaction_mode, ...), so they were unreachable under their documented names and collided in the global GUC namespace. All 43 are now properly qualified; user-facing names are unchanged for the ones that already were. - The GUC C variables were exported as generic globals (
name,data_dir,metrics,key_file, ...) from a library loadedRTLD_GLOBAL, a collision hazard against the backend and other extensions. They now carry apgraft_guc_prefix. GUC names are unaffected. - The background worker no longer busy-polls. It waits on its latch, honours
CHECK_FOR_INTERRUPTS()and SIGHUP, unblocks signals before its startup delays (it previously ignored shutdown requests for the first several seconds), and emits periodic diagnostics roughly once a minute instead of several log lines every 100 ms. src/pgraft_go.his no longer tracked; it is generated bygo build -buildmode=c-sharedand removed bymake clean, so hand edits were silently reverted on the next build.- Removed dead code:
src/pgraft_go_apply.c(never inOBJS, no references), and the unreachableprocessRaftReady,processRaftTicker,processMessage,sendToNode,broadcastToAllNodes,processCommittedEntry,handleIncomingMessageandgetClusterNodesin the Go layer. Several were live hazards:sendToNodewrote frames without holding the connection mutex,processCommittedEntrymutated indices non-atomically, andgetClusterNodesiterated array indices as if they were node IDs and fell back to a hardcoded{1, 2, 3}voter set. - CI now builds with Go 1.23, matching
src/go.mod.
- The peer transport is unauthenticated and unencrypted. A node ID is taken
from the wire and trusted, and the
pgraft.*_cert_fileGUCs are accepted but not yet used. Do not expose the peer port outside a trusted network. - Node IDs are assigned from each member's position in
pgraft.initial_cluster, so every node must list members in the same order or they will disagree about identities. - SQL/DDL replication is not implemented; only shared-memory key/value entries are applied.
1.0.0 - 2024-01-XX
- Initial release of pgraft
- Raft consensus protocol integration using etcd-io/raft library
- Automatic leader election with quorum-based voting
- Crash-safe log replication across cluster nodes
- Background worker architecture for Raft state machine
- SQL functions for cluster management and status monitoring
- Support for dynamic node addition and removal
- Persistent storage for Raft state and log entries
- Split-brain prevention with mathematical guarantees
- Comprehensive documentation and examples
- GitHub Actions CI/CD workflows for multiple PostgreSQL versions (16, 17, 18)
- RPM and DEB packaging support
- Prometheus metrics integration
- PostgreSQL extension registration and control files
- Written in PostgreSQL C with Go integration for Raft consensus
- Compatible with PostgreSQL versions 16, 17, and 18
- Zero compilation warnings
- Follows PostgreSQL C coding conventions
- Comprehensive error handling and logging
- Production-ready quality and testing