All notable changes to plex-postgresql will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- 92% unsafe reduction — raw pointer dereferences 806→59. Internal functions use safe
&mut PgStmt/&mut PgConnectionreferences. Business logic is now Rust-safe; unsafe confined to FFI boundary. - PgStmt Vec-based allocation — inline
[T; 1024]arrays replaced withVec<T>that grow on-demand. 0-param SELECT uses 540 bytes (was 88KB). Allocated viaBox::new(), freed via Drop. - Rust Mutex on PgStmt —
pthread_mutex_treplaced withstd::sync::Mutex<()>. Auto-initialized, no manual pthread calls. - 138 functions de-FFI'd — removed
#[no_mangle] extern "C"from Rust-only functions, enabling inlining and eliminating C ABI overhead. - Safe orig_sqlite3 accessors — 55 static mut function pointer reads encapsulated in safe accessor functions via macro-generated wrappers.
- Memory leak (1.8GB→59MB) — PGresult leak in re-execution path, transaction control routing mismatch (BEGIN/COMMIT sent to PG instead of skipped), cached_result ref_count not released on stmt free.
- 3 deadlocks eliminated — connection mutex self-deadlock (PTHREAD_MUTEX_RECURSIVE), ABBA deadlock (stmt→LOGGER mutex), connection mutex convoy (reduced lock hold time).
- Stack overflow — 512KB thread-local buffers heap-allocated via
vec![].into_boxed_slice()for Plex's 544K worker threads. - 11 data races fixed — atomic counters/flags, seqlock on crash buffers, OnceLock for lazy config, eager hook resolution, AtomicPtr for fishhook linked list.
- All clippy warnings resolved — zero warnings on Linux CI with
-D warnings.
- Full Rust shim runtime — all interpose/runtime C code eliminated. The shim is now 100% Rust (compiled to a single
.dylib/.sovia static linking). No C object files remain. Rust's ownership model, borrow checker, and type system prevent entire classes of bugs that plagued the C implementation: use-after-free, buffer overflows, double-free, null pointer dereference, and data races are caught at compile time rather than discovered in production. - Module split — large monolithic Rust files split into focused submodules:
db_interpose_column/,db_interpose_common/,db_interpose_step/,db_interpose_prepare/,pg_client/,pg_statement/, and 15+ other module directories. - Thread-local text buffers heap-allocated — fixes stack overflow on Plex worker threads (544K stacks) by using
Box::new()for the 512KB column text buffer pool.
- Connection mutex self-deadlock —
PgConnection.mutexchanged fromPTHREAD_MUTEX_DEFAULTtoPTHREAD_MUTEX_RECURSIVE, matchingPgStmt.mutex. Fixes self-deadlock whenensure_pg_result_for_metadatacallsresolve_column_tables_implon the same connection. - Connection mutex convoy —
ensure_pg_result_for_metadatanow releases the connection mutex before callingresolve_column_tables, reducing lock hold time and preventing thread convoy effects under concurrent metadata access. - ABBA deadlock (stmt mutex / LOGGER mutex) — logging calls removed from inside stmt/conn mutex scopes across all column accessor modules. Logger uses
try_lockwith stderr fallback when contended. - Double-lock on stmt mutex —
validate_type_consistencymoved outside the stmt mutex scope in scalar accessors.ensure_pg_result_for_metadataandallocate_fake_sqlite_valuemoved outside stmt mutex in metadata/value accessors. - rusqlite 0.32 compatibility — test files updated to use FFI
sqlite3_prepare_v2instead of removedStatement::as_raw()method.
- SQL translator fully migrated to Rust (Phase 1) — the entire SQLite-to-PostgreSQL SQL translation pipeline now runs on Rust's
sqlparser-rsAST engine. The C translator (sql_tr_*.c,sql_translator.c) has been removed. 525 Rust tests cover all translation paths (318 lib + 54 batch1 + 51 batch2 + 42 batch3 + 60 batch4). - PG modules migrated to hybrid C/Rust (Phase 2) — all 7 backend modules now have their core logic in Rust with thin C shims:
pg_config,pg_logging,pg_mem_telemetry,shim_alloc,pg_query_cache,pg_statement,pg_client. ~550 C tests across 25 suites continue to pass. transform_exprrefactored to&mut Expr— functions.rs, keywords.rs, and query.rs now use in-place mutation instead of by-value transform, reducing unnecessary AST cloning.- Log level demotion — 5 informational
LOG_ERRORmessages demoted toLOG_INFO(pool init, fresh connection succeeded). These are filtered at the defaultPLEX_PG_LOG_LEVEL=ERRORsetting, reducing log noise in production. - Docker and CI builds updated for Rust — Dockerfiles, build_shim_musl.sh, and GitHub Actions workflows now install the Rust toolchain and build the sql-translator staticlib.
- C SQL translator —
sql_tr_*.c,sql_translator.c,sql_translator_internal.hand allSQL_TR_OBJSfrom Makefile. The Rust translator is now the sole implementation.
- Git repository size — removed accidentally committed
rust/sql-translator/target/from all history viagit-filter-repo. Repository size reduced from 243MB to 60MB.
- Duplicate prepared statement handling (SQLSTATE 42P05) — after a Plex restart (while PG keeps running), the shim's empty cache would try to re-prepare statements that already exist on the PG backend. Now detects SQLSTATE
42P05viapg_is_duplicate_prepared_stmt()(locale-independent), replaces fragilestrstr(err, "already exists")checks. Fixed missing 42P05 handling indb_interpose_exec.canddb_interpose_column.c(METADATA_DESCRIBE path). - DEALLOCATE ALL at connection init — new connections now run
DEALLOCATE ALLto clean up orphaned prepared statements from previous shim instances. Eliminates PG log spam. - METADATA_DESCRIBE cache lookup — the column metadata path now checks the local stmt cache before calling
PQprepare, avoiding unnecessary round-trips and PG-side errors.
- Prepared statement cache unit tests — 21 new tests covering hash function, cache add/lookup/clear, SQLSTATE 42P05/26000 detection, edge cases, and cache eviction. Total: 299 tests.
- Stale prepared statement recovery after PG restart — after a PostgreSQL restart, the shim's prepared statement cache could reference statements that no longer exist on the server. Now detects SQLSTATE
26000(invalid_sql_statement_name), clears the local cache (no DEALLOCATE round-trips), and lets the retry wrapper re-prepare and re-execute. Covers all 6 error handlers in step and exec paths.
sqlite3_execretry + reconnect (Issue #8) —sqlite3_execnow has the same pre-flight connection health check and retry wrapper thatsqlite3_stepalready had. Dead connections are detected immediately viaPQstatus, reconnected inline, and retried withPLEX_PG_RETRY_DELAYSbackoff. Previously,sqlite3_execwould silently swallow connection errors.
- Docs: add
PLEX_PG_IDLE_TIMEOUTeverywhere — added idle timeout env var to Dockerfile.standalone, INSTALL.md (all platform sections), and Linux install wrapper script.
- Pool auto-grow (Issue #9) — Connection pool now automatically grows from configured size up to
POOL_SIZE_MAX(200) when Plex creates more threads than available slots. Prevents permanent thread lockout whenPLEX_PG_POOL_SIZEis set below peak thread count. The existing reaper closes idle connections when demand drops. PLEX_PG_IDLE_TIMEOUTenvironment variable — configures how long idle connections are kept before the reaper closes them (default: 300s, minimum: 10s).- Stress test suite —
test_pool_autogrow,test_stress_load,test_pool_exhaustion,test_pool_modesfor pool behavior validation under load. - Makefile targets —
test-stressandtest-pool-exhaustionfor running pool stress tests.
configured_pool_sizeis now atomic (_Atomic int) for lock-free auto-grow via CAS.
- Docker standalone: chown -R delay on large libraries — removed two
chown -R plex:plexcalls fromstandalone-entrypoint.shthat caused massive delays on multi-TB libraries. Plex handles ownership itself via40-plex-first-run. Also fixes triple-chown issue whenPLEX_UID/PLEX_GIDare set. (PR #7 by @sjdaws)
PLEX_PG_RETRY_DELAYSenvironment variable — configures the retry backoff schedule for both pool-level and step-level PG reconnection. Comma-separated list of delays in milliseconds (default:500,1000,2000,3000,4000). Example:PLEX_PG_RETRY_DELAYS=200,500,1000for faster recovery on a local PG.
- Plex doesn't recover after PostgreSQL restart (Issue #8) — Two-layer fix ensures zero endpoint failures after PG restarts:
- Pool-level retry (
pg_client.c):pool_get_connection()Phase 5 retries with exponential backoff (500ms→4s, 5 retries, ~10.5s total) instead of returning NULL when all slots are unavailable. This covers threads needing new connections. - Step-level retry (
db_interpose_step.c): Newmy_sqlite3_step()wrapper catchesSQLITE_ERRORfrom any PG connection failure (flagged via thread-localstep_pg_conn_error), resets statement state withpg_stmt_clear_result(), waits with exponential backoff, and retries. This covers threads whose existing connections died mid-query (PQsend failures, CONNECTION_BAD). All existing mutex/lock patterns are preserved — no deadlock risk.
- Pool-level retry (
- Blobs UNIQUE index —
blobstable was missingUNIQUE INDEX idx_blobs_linked_type_id_blob_type ON blobs(linked_type, linked_id, blob_type), causing ON CONFLICT upsert errors. Added to schema and doctor.sh.
- Docker fresh-install crash —
blobs.dbwas excluded from the PG dummy-shadow path by!is_blobs_db_path(), causing a "no such table: schema_migrations" crash on first start. Removed the exclusion so blobs.db uses the PG dummy shadow like all other databases. - Docker blobs schema_migrations rewrite —
rewrite_blobs_schema_migrations()renamed the table toblobs_schema_migrationswhich doesn't exist in PG. Disabled with#if 0since both databases share the sameschema_migrationstable. - Docker claim flow crash (linuxserver) —
init-plex-claimstarted Plex temporarily withoutLD_PRELOAD, causing immediate crash. Fixed by patching the claim script at build time to include the shim. - Docker migration: generated columns — PG
subtypeonmetadata_itemsis a generated column; COPY fails. Fixed: filteris_generated = 'ALWAYS'columns from COPY target list. - Docker migration: check constraints —
chk_not_orphanblocked COPY for orphaned records. Fixed: drop constraints before COPY, restore with NOT VALID after. - Docker migration: script path —
$(dirname "$0")resolved wrong in standalone container. Fixed:SHIM_DIRfallback. - Docker: missing python3 + migrate_table.py in both Docker images. Added to Dockerfile and Dockerfile.standalone.
- Docker: missing source files in Dockerfile.standalone — gcc command was out of sync. Synced with Dockerfile.
- SQL: GROUP BY / NULLS FIRST corrupted non-SELECT —
add_nulls_first_ordering()andfix_group_by_strict_complete()added ORDER BY/GROUP BY rewrites to DELETE/UPDATE statements. Both now skip non-SELECT statements. streaming_activerace condition — Changed fromvolatile intto_Atomic intfor correct cross-thread visibility.
- Docker claim detection —
check_plex_claim()in docker-entrypoint.sh warns if PLEX_CLAIM is set but server is already claimed. - PLEX_CLAIM example in docker-compose.yml.
- Diagnostic logging for real SQLite prepare failures (helps debug shadow issues).
- 17 connection isolation tests updated for
_Atomic intsemantics. - Total: 278 tests (220 SQL + 41 shadow elimination + 17 connection isolation).
- Log level fixes: "Result from different connection" → DEBUG, "finalize: BUG" → INFO, "LOOP DETECTED" → INFO. Removed PREPARE INSERT debug dump.
- Aggregate decltype returns NULL —
sqlite3_column_decltype()for aggregate expressions (count(*),sum(),min(),max(),avg()) now returns NULL instead of"INTEGER", matching real SQLite behavior. This fixesstd::bad_castcrashes in SyncCollections (MetadataCollection.cpp:522), "Saving activity history aborted", and "ViewStateSync exception". - camelCase alias quoting — PostgreSQL lowercases unquoted identifiers; aliases like
blankKeyTaggingId,nonblankKeyId,grandparentsSettingsare now auto-quoted. Two-pass approach excludes SQL type keywords (INTEGER, TEXT, etc.) in CAST expressions. - Logging fd leak after fork — child processes forked by Plex no longer close the parent's log file descriptor on exit.
- Log noise — "STREAM: zero rows returned", "RESOLVE_TABLES: alternate connection", "drained N results after cancel" downgraded from ERROR to DEBUG.
- Dummy prepare with named parameters — PG-routed queries build a dummy SQLite statement using actual named parameters from the SQL translator, removing more shadow SQLite dependency.
- PQdescribePrepared for column metadata —
column_count()andcolumn_name()use PostgreSQL's describe protocol instead of shadow SQLite. - Decltype cache from PG catalog — column types resolved from
information_schema.columnscache. - Transaction commit guards in connection pool release.
- blobs.db schema_migrations routing to PostgreSQL.
- 11 new camelCase alias quoting tests. Total: 261 tests (220 SQL + 41 shadow elimination).
- Docker build: missing source files —
Dockerfileandbuild_shim_musl.shwere missingdb_interpose_value.c,db_interpose_common.c,platform_backtrace.c,pg_mem_telemetry.c, andshim_alloc.c, causing symbol lookup errors at runtime in Docker containers. - Windows Docker: CRLF line endings — added
.gitattributesto enforce LF line endings for shell scripts, Dockerfiles, and source files. Windows git converts LF→CRLF by default, breaking shebangs inside containers (fixes #6).
- Shim memory tracker — opt-in allocation tracking via
PLEX_PG_ALLOC_TRACK=1(summary every 60s) andPLEX_PG_ALLOC_TRACE=1(top 15 unfreed allocation sites with file:line). Zero overhead when disabled.
- Connection isolation during streaming —
resolve_column_tables()andpreload_decltype_cache()calledPQexec()on the streaming connection, consuming pending results. NextPQgetResultreturned NULL, making Plex think no migrations had run, triggering full re-migration andstd::bad_castcrash. Both functions now acquire an alternate pool connection when the passed connection hasstreaming_active=1. - Pool connection acquisition skips streaming connections — fast path (TLS cached slot) and PHASE 1 loop both check
streaming_activeflag before returning a connection. PQcancelbefore drain loops — 6 drain loops (4 indb_interpose_step.c, 2 inpg_statement.c) now cancel the server-side query before draining, preventing hangs on large result sets.
streaming_activeflag onpg_connection_t—volatile intset when streaming starts, cleared on all completion/error/reset/finalize paths.- Dummy shadow statement fallback — when shadow SQLite prepare fails for READ queries, builds a dummy
SELECT 1 WHERE ? IS NOT NULL AND ...with matching parameter count sosqlite3_bind_*calls succeed and the query runs purely on PostgreSQL. - DDL
IF NOT EXISTSinjection — shadow SQLiteCREATE TABLE/CREATE INDEXstatements getIF NOT EXISTSadded automatically. - 17 connection isolation tests (
test_connection_isolation.c) — streaming_active lifecycle, pool isolation, resolve/decltype guards, regression, multi-threaded. - 20 shadow fallback tests (
test_shadow_fallback.c) — parameter counting, dummy generation, edge cases, end-to-end. - Total: 798 tests across 24 suites.
- Single-row streaming mode — READ queries use
PQsetSingleRowModefor row-by-row streaming instead of fetching entire result sets into memory. Reduces memory pressure for large queries.
- SQL translation bugs — placeholder counting missed parameters after string literals; upsert translation failed when column list was absent; string quoting edge cases.
- Removed SyncCollections COMPAT skips — the blank-key cleanup and tag aggregation queries were being intercepted and replaced with empty results to avoid
std::bad_castcrashes. The root causes (dt_integer(8)decltype mapping and column alias fixes) were already resolved in v0.9.23, making these skips unnecessary. Removing them restores collection data and eliminates all 223 "Failed to generate a query" LPE errors at startup.
- JSON
->>operator rewritten correctly —col ->> '$.key'now translates tocol::json->>'key'(native PostgreSQL JSON extraction). Previous LIKE-based hack consumed bind parameters, causing "bind message supplies N parameters, but prepared statement requires M" errors on Plex v1.43.0.10492 voice-activity-detection queries. instr()function now translated — SQLite'sinstr(haystack, needle)is translated to PostgreSQL'sSTRPOS(haystack, needle). Fixes "function instr(text, unknown) does not exist" error on Last.fm blacklist queries.- Migration CSV truncation bug — the
sqlite3 -csvexport silently truncated TEXT fields larger than ~8KB containing embedded quotes. Replaced with Python bridge (migrate_table.py) usingCOPY FROM STDINwith tab-delimited data. Affected 133 rows inmedia_parts.extra_dataon a typical library. - CI
leakstool on Linux —test-stmt-freeandtest-bind-mismatchtargets now skip macOSleakstool on Linux instead of failing with "leaks: not found".
scripts/migrate_table.py— Python bridge for lossless SQLite-to-PostgreSQL data transfer via COPY protocol.- Truncated JSON detection in
doctor.sh— checksextra_datacolumns across media_parts, media_items, metadata_items, metadata_item_settings for invalid JSON and auto-repairs by trimming the redundanturlfield. - Data integrity check in migration —
migrate_lib.shverifies JSON integrity after migration completes. - 3 new tests:
instr()translation (2), real Plex VAD query with 3 bind params (1). Total: 738 tests.
- All
TRACE_BADCASTandTRACE_PREPAREdiagnostic messages downgraded fromLOG_ERRORtoLOG_DEBUG— they are opt-in debug traces, not errors.
- Optional memory telemetry — set
PLEX_PG_MEM_TELEMETRY=1to log per-subsystem allocation stats every 60s. Tracks bind_text, bind_hex, bind_value_blob, column cached_blob, column decoded_blob, and statement sweep frees. Default off, zero overhead when disabled. - Telemetry passed through
make runviaPLEX_PG_MEM_TELEMETRYenv var.
- 30-minute production measurement confirmed shim allocates only 14KB total under normal Plex load — memory growth is Plex-internal, not shim-caused.
- Statement cleanup leak window in
pg_stmt_free— captured bind values are now freed across allMAX_PARAMSslots, not only up toparam_count.
test_stmt_free_param_sweepregression test to verify full parameter slot cleanup at statement teardown.test_bind_index_mismatch_cleanupregression test to cover cleanup safety when bind index usage diverges from translatedparam_count.
- Included both new regression tests in
unit-testandci-testtargets.
LPE: only library URIs are allowed right nowerrors on startup (142-221 per startup)- Root cause: Plex's
extra_datacolumn stores JSON blobs containing"pv:uri":"server://<machineId>/com.plexapp.plugins.library/library/..."URIs. Plex's LPE parser only acceptslibrary://scheme. - Solution: Rewrote
rewrite_server_library_uri()to scan text values for embeddedserver://URIs and rewrite them tolibrary://inline. Handles both standalone URIs and JSON-embedded URIs. - The data in PostgreSQL is correct (identical to original SQLite); the rewrite happens at read-time only.
- Root cause: Plex's
- Off-by-one in
needle_lenconstant — hardcoded 36 instead of actual 37. Found by new unit tests. Replaced all hardcoded string lengths withsizeof() - 1. /library/metadata/<id>returning HTTP 500 withstd::bad_cast— returndt_integer(8)for OID=20 timestamp columns./library/metadata/<id>/relatedreturning HTTP 500 withstd::bad_cast— returnSQLITE_NULLformetadata_type=18(collection/folder) in related-items queries.DatabaseFixups/SyncCollectionsthrowingstd::bad_cast— skip two problematic query patterns with empty-result dummy statements.
- 13 unit tests (
test_uri_rewrite.c) — standalone URIs, JSON-embedded URIs, multiple URIs, edge cases (NULL, empty, small buffer, no match, partial match). - Total: 777 tests across 24 suites.
- Removed
LPE_URI_READdiagnostic trace (was ERROR-level, spammed 884 lines per startup). - Cleaned up dead
col_name_for_logvariable and redundant comments.
- Extracted
db_interpose_value.cfromdb_interpose_column.c— all 7sqlite3_value_*functions (type, text, int, int64, double, bytes, blob) now in separate module. Column.c: 2065 → 1769 lines.
- macOS
sqlite3_column_decltypenot intercepted — was missing from fishhook rebindings, so Plex's SOCI type-mapping logic (my_sqlite3_column_decltype, 150+ lines) was never called on macOS. - Linux
sqlite3_column_decltypewrapper bypassed SOCI type mapping — routed toorig_sqlite3_column_decltypeinstead ofmy_sqlite3_column_decltype. - macOS fallback
load_sqlite_fallback()only loaded ~11 of ~60 symbols — replaced with sharedcommon_load_sqlite_symbols()covering all functions.
- Extracted
common_load_sqlite_symbols()intodb_interpose_common.c— single source of truth for all ~60dlsymlookups, used by both macOS fallback and Linuxload_original_functions(). - Extracted
platform_backtrace.c— unified backtrace module with#ifdef __APPLE__for platform-specific frame collection and symbol resolution. Removed ~300 lines of duplicated code.
- 66 new platform parity tests (
test_platform_parity.c) — symbol loading completeness, if-not-set pattern, idempotency, callable pointer verification, backtrace output format. - Total: 764 tests across 23 suites (CI: 722 tests, 19 suites).
-
GitHub Actions unit test pipeline
- New
unit-testsjob in.github/workflows/ci.ymlruns 657 tests across 18 suites on every push and PR. - New
ci-testMakefile target for CI-safe test subset (excludes LD_PRELOAD tests). - Fixes for Linux/GCC portability:
pthread_getattr_npfor stack tests,stddef.hforptrdiff_t, graceful__cxa_demangleskip.
- New
-
~160 new unit tests for SQL translator and upsert (540 -> 698 total)
- Upsert: 6 -> 59 tests covering all 28 conflict targets, schema prefix stripping, special column handling.
- Case booleans: 2 -> 14 tests. Integer/text mismatch: 9 tests. DDL types: 12 tests. Keywords: 18 tests.
- Forward reference joins: 5 tests. Null sorting: 6 tests. Plex pipeline: 3 tests.
fix_group_by_strict_complete: 15 direct tests.add_nulls_first_ordering: 4 tests.- typeof remapping, strftime, unixepoch, json_each, placeholder edge cases, operator spacing, COLLATE NOCASE.
- sql_tr_upsert.c: schema prefix not stripped before
metadata_item_settingsspecial case comparison. - sql_tr_query.c: fast-path in
translate_case_booleansmissing" 0)"and" 1)"patterns. - sql_tr_types.c: fast-path in
sql_translate_typesmissing" datetime"check.
- Block junk metadata inserts with both
library_section_idandmetadata_typeNULL (orphan rows).- Added
chk_not_orphanCHECK constraint to schema anddoctor.sh.
- Added
- schema_migrations conflict handling: added
ON CONFLICT DO NOTHINGfor INSERTs to prevent UNIQUE violation crash. - Placeholder translator: only track single-quote strings, not double-quote identifiers.
- Duplicate assignment dedup: handle backtick-quoted columns, consume removed
$Nparams with COALESCE. - NULLS FIRST ordering added for GROUP BY queries (SOCI compatibility).
- Downgraded
COLUMN_TEXT_NO_STMTfrom ERROR to DEBUG for non-PG databases.
- Auto-reconnect PostgreSQL after connection loss
step()READ/WRITE retries once after 500ms if pool returns NULL connection.- If
PQresetfails onCONNECTION_BAD, tries freshPQconnectdbinstead of giving up. pg_pool_check_connection_healthuses same fallback to fresh connection when reset fails.- Fixes HTTP 500 on all library endpoints after PostgreSQL restart or after Plex is killed with SIGKILL.
- macOS: shim dylib installed into Plex.app bundle
install_wrappers.shcopies dylib intoPlex.app/Contents/MacOS/instead of referencing external paths.- Scanner uses
@loader_pathforLC_LOAD_DYLIB(portable, no absolute paths). - Server wrapper simplified (no more placeholder sed, no auto-build).
- Uninstaller cleans up dylib from Plex.app.
-
macOS wrapper portability and migration state correctness
- Removed hardcoded machine-specific paths from generated server wrapper.
- Wrapper now uses install-time shim placeholders and user-home defaults.
- SQLite shadow
schema_migrationsnow syncs from PostgreSQL (instead of only insertingpg_adapter_1.0.0).
-
macOS scanner uninstall reliability
- Installer now keeps
Plex Media Scanner.originalbackup before patching. - Uninstaller now restores scanner when backup exists and prints a clear warning when restore is impossible.
- Prevents silent "uninstall succeeded" state while scanner stays patched.
- Installer now keeps
- GitHub Actions Linux release pipeline
- Added
.github/workflows/release-linux-artifacts.ymlto build Linux release binaries on tag push. - Builds
db_interpose_pg-linux-x86_64.soanddb_interpose_pg-linux-aarch64.soand uploads them to the GitHub release. - Supports manual re-run via
workflow_dispatchwith ataginput.
- Added
-
CI tag checkout behavior for manual runs
- Workflow now checks out the requested tag ref instead of always building
mainduring manual dispatch.
- Workflow now checks out the requested tag ref instead of always building
-
Architecture-specific PostgreSQL builder flags in Dockerfiles
- Made PostgreSQL build flags architecture-aware to improve release build stability in CI.
- Files:
Dockerfile,Dockerfile.standalone
-
Linux: Plex crash loop with
Received unexpected async signal 17(SIGCHLD)- Added Linux
sigaction()guard to keepSIGCHLDignored in the main Plex Server/Scanner process. - Prevents child process exits (plugins/scanner helpers) from triggering the async signal crash path under
LD_PRELOAD. - Files:
src/db_interpose_core_linux.c
- Added Linux
-
Linux child-process safety with
LD_PRELOADinheritance- Added passthrough mode for non-target processes so plugin/helper processes keep original SQLite behavior.
- Explicitly resolve and use original SQLite symbols in non-server/scanner processes.
- File:
src/db_interpose_core_linux.c,src/pg_config.c,src/db_interpose.h,src/db_interpose_common.c
-
Docker migration parity: prevent migration reruns from SQLite shadow DB
- Added
schema_migrationssync from PostgreSQL to SQLite shadow databases during init. - Keeps SQLite and PostgreSQL migration state aligned, avoiding repeated migration attempts.
- Files:
scripts/standalone-entrypoint.sh,scripts/docker-entrypoint.sh
- Added
-
Standalone compose defaults to production log level
- Switched
docker-compose.standalone.ymltoPLEX_PG_LOG_LEVEL=ERRORafter stabilization.
- Switched
-
Standalone Docker image hardening
- Updated
Dockerfile.standaloneto use build-time run-script injection and include schema/type metadata setup. - Replaced CrashUploader with a no-op binary in the standalone image to avoid unnecessary crash upload behavior in Docker.
- Updated
- Makefile:
make installon Linux tried to buildfishhook.o(macOS-only)$(OBJECTS)always includedsrc/fishhook.o, even on Linux where fishhook.c requires macOS headers (mach/mach.h)make installdepended on$(TARGET)which used the default$(OBJECTS)instead of$(LINUX_OBJECTS)- Solution:
$(OBJECTS)is now platform-conditional — includesfishhook.oonly on Darwin - Fixes #5
Dockerfile.standaloneforplexinc/pms-dockerusers- Multi-stage build: Alpine builder (musl) + plexinc/pms-docker runtime
- Builds libpq from source without OpenSSL to avoid
ENGINE_*symbol conflicts - Includes musl symlink setup and locale configuration
- Closes #5
- CRITICAL: Excessive logging causing system freeze and kernel panic
- ROOT CAUSE: 18 debug/trace
LOG_ERRORandLOG_INFOcalls on hot paths fired on every database query - Even at
PLEX_PG_LOG_LEVEL=ERROR, debug statements likeRACE_DEBUG,CACHED INSERT metadata_items,STEP metadata_items INSERT,play_queue_generatorsparams,DEBUG_TRACE STEP_EXIT,PREPARED CHECK/PATH/STMT,EXEC_PREPARED,STEP_TRACE/DONE/ROWwere logged - Each log call: malloc(4KB) + mutex lock + unbuffered write syscall + mutex unlock + free
- At thousands of queries/second this caused 34+ GB/day disk writes and severe mutex contention
- Thread starvation led to 63 GB memory exhaustion, 29 swap files, WindowServer watchdog timeout, and kernel panic
- Solution: Demoted all debug/trace statements to
LOG_DEBUGso they are completely skipped at ERROR level (no malloc, no mutex, no syscall) - Files:
src/db_interpose_step.c,src/db_interpose_column.c
- ROOT CAUSE: 18 debug/trace
- Stack overflow from circular parent_id references in metadata_items
- ORDER BY syntax error in GROUP BY query translation
- Database triggers to prevent circular parent references
- Triggers to auto-fix orphan seasons on episode insert
-
CRITICAL: Kernel panic caused by fflush(NULL) deadlock - System crash prevention
- ROOT CAUSE:
fflush(NULL)indb_interpose_step.c:653flushed ALL stdio streams while holding log mutex - 14+ postgres processes blocked on
_fwalk → sflush_locked → flockfile - Triggered WindowServer watchdog timeout (120s) and kernel panic
- Solution: Removed
fflush(NULL)call - logging already flushes per-line - File:
src/db_interpose_step.c
- ROOT CAUSE:
-
CRITICAL: SOCI "Null value not allowed for this type" exceptions - HTTP 500 errors
- ROOT CAUSE:
column_type()returnedSQLITE_NULLfor NULL column values - SOCI checks
column_type()BEFORE callingcolumn_int(), throws exception on NULL - Affected endpoints:
/library/all/top,/hubs/promoted,/library/metadata/* - Specifically: MetadataCounterCache query with NULL
parent_idvalues - Solution: Return declared column type (INTEGER, TEXT, etc) instead of
SQLITE_NULL column_int()already returns 0 for NULL (matching SQLite behavior)- File:
src/db_interpose_column.c(both cached and non-cached paths)
- ROOT CAUSE:
- The NULL handling fix does NOT break existing behavior
- SQLite's
column_int()returns 0 for NULL values - our shim does the same - SOCI's strict type checking is the real issue, this is a workaround
- Similar to v0.8.12 aggregate function TEXT workaround
- Reduce shim log noise by downgrading high-frequency DECLTYPE/COLUMN_TYPE/COLUMN_TEXT_INTEGER diagnostics to DEBUG.
-
CRITICAL: std::bad_cast exception in TV shows endpoint - MetadataCounterCache rebuild crash
- ROOT CAUSE: Plex's SOCI version has bug with BIGINT aggregate functions in row access
- Sequence: count() returns BIGINT (OID 20) → declared as "BIGINT" → SOCI uses column_text() → parses as integer → row.get<int64_t>() → std::bad_cast
- SOCI's SQLite backend in Plex uses column_text() for ALL integer types, not column_int64()
- Type checking during text-to-int conversion fails for aggregate BIGINT values
- Impact: TV shows endpoint returned HTTP 500, "Exception handled: std::bad_cast"
- Solution: Force aggregate functions (count, sum, max, min, avg) to declare as TEXT type
- SOCI accepts TEXT → integer conversion without strict type checking
- Workaround in
column_decltype()detects aggregate column names and returns "TEXT" - Files:
db_interpose_column.c(line 1573),pg_statement.c(improved type mappings) - Related: SOCI Issue #1190 (identical bug, fixed in SOCI 4.1.0)
-
Improved PostgreSQL type mappings - Correct BIGINT decltype declaration
- INT2 (OID 21) → "INTEGER" (unchanged for SOCI compatibility)
- INT4 (OID 23) → "INTEGER" (unchanged, correct)
- INT8 (OID 20) → "BIGINT" (was "INTEGER", now correct)
- File:
pg_statement.c(pg_oid_to_sqlite_decltype function) - Note: SMALLINT not used due to SOCI compatibility issues
-
CRITICAL: INSERT...RETURNING lastval() transaction boundary bug - playQueues 500 errors (final fix)
- ROOT CAUSE:
lastval()only works within the same transaction, but libpq uses autocommit mode - Sequence: INSERT...RETURNING executes in transaction T1 → commits → lastval() queries in transaction T2 → fails
- PostgreSQL closes transaction after each PQexec() in autocommit mode
lastval()error: "lastval is not yet defined in this session" or returns stale values- Solution: Capture RETURNING id immediately and store in
pg_connection_t->last_insert_rowid - Modified
last_insert_rowid()to return stored value instead of callinglastval() - Stores ID in all three paths: prepared statements, cached statements, and direct exec
- Files:
db_interpose_step.c(2 locations),db_interpose_exec.c,db_interpose_metadata.c
- ROOT CAUSE:
-
CRITICAL: Explicit transaction handling implementation - Root cause fix for transaction data loss
- ROOT CAUSE: BEGIN/COMMIT/ROLLBACK were skipped as no-ops, PostgreSQL never received them
- Plex sends: BEGIN → INSERT → COMMIT, but shim executed: (skip) → INSERT → (skip)
- PostgreSQL used implicit transaction mode, transactions never committed
- Data appeared to succeed (lastval() worked) but was rolled back on connection reuse
- Solution: Removed transaction commands from skip patterns, implemented explicit execution
- Added
is_transaction_command()to detect BEGIN/COMMIT/ROLLBACK - Execute transaction commands on PostgreSQL in
db_interpose_exec.c - Track transaction state in
pg_connection_t.in_transactionfield - Files:
pg_config.c,pg_config.h,db_interpose_exec.c,db_interpose_prepare.c
-
CRITICAL: Connection mismatch in lastval() - Wrong sequence values returned from different connection
- Root cause: INSERT uses
pg_get_thread_connection()butlastval()usedpg_find_connection() - Between INSERT and lastval(), pool state can change (thread steals slot, cache invalidated, etc.)
- Result: INSERT on connection A succeeds,
lastval()queries connection B, returns wrong ID - Solution: Use
pg_get_thread_connection()for metadata functions on library.db - Modified
my_sqlite3_last_insert_rowid(),my_sqlite3_changes(),my_sqlite3_changes64() - Guarantees same thread-local connection for INSERT and metadata retrieval
- Root cause: INSERT uses
- Transaction commands (BEGIN/COMMIT/ROLLBACK) now executed on PostgreSQL instead of skipped
- Transaction state tracking via
in_transactionfield in connection structure - Metadata functions (
lastval(),changes()) now use thread-local connection for library.db - Ensures transaction consistency across all operations in a single thread
- v0.8.10 implements explicit transaction handling (ROOT CAUSE fix)
- Complements v0.8.9.6 (pool reuse) and v0.8.9.7 (connection mismatch) fixes
- All three fixes work together for complete transaction correctness
- CRITICAL: Uncommitted transactions lost on connection pool reuse - playQueues 500 errors
- Root cause: PQreset() in PHASE 2 of
pool_get_connection()aborts uncommitted transactions - Sequence: Thread A INSERTs -> releases connection -> Thread B reuses -> PQreset() rolls back
- lastval() succeeds (sequence persists) but actual INSERT data is rolled back
- Result: 404 on subsequent GET requests despite successful lastval() return
- Solution: Check PQtransactionStatus() and COMMIT before releasing connection (pg_close_pool_for_db)
- Defense-in-depth: Also check and COMMIT before PQreset() in PHASE 2 reuse
- PostgreSQL implicit transactions now properly committed before pool slot release
- Root cause: PQreset() in PHASE 2 of
pg_close_pool_for_db()now commits pending transactions before marking slot as SLOT_FREEpool_get_connection()PHASE 2 now commits pending transactions before PQreset()
-
Row index -1 out of bounds error - libpq "row number -1 is out of range" error
- Root cause: WRITE statements with RETURNING set
current_row = -1 - Column functions using fake values could access libpq with invalid row index
- Added
row_idx >= 0check to all fake value access points - Column functions now handle all PostgreSQL statements properly (not just those with results)
- Root cause: WRITE statements with RETURNING set
-
INSERT...RETURNING result storage causing issues
- Don't store RETURNING result for WRITE statements
- SOCI uses
lastval()via SQL translation, not the RETURNING columns - Prevents confusion from mixing WRITE and READ result handling
- Column functions now use simpler
pg_stmt->is_pgcheck instead ofis_pg == 2 || (is_pg == 1 && result) - This ensures proper fallback behavior for all PostgreSQL-intercepted statements
- Memory corruption when clearing metadata results - Race condition in PQclear()
- Root cause: v0.8.9's
clear_metadata_result_if_needed()calledPQclear()during bind operations - This caused race conditions when multiple threads accessed the same prepared statement result
- Crash in libpq's
resetPQExpBufferwith corrupted address0x4d55545a00000000(ASCII "MUTZ") - Solution: Don't call
PQclear()in bind functions - setmetadata_only_result = 2instead - Actual cleanup now handled safely in
step()where proper locking is in place
- Root cause: v0.8.9's
clear_metadata_result_if_needed()now sets flag to 2 instead of calling PQclear()step()checks formetadata_only_result == 2to safely cleanup and re-execute
- Metadata-only results blocking step() re-execution - "Step didn't return row" errors
- Root cause:
ensure_pg_result_for_metadata()executed queries BEFORE parameters were bound - This cached 0-row results, and
step()saw the cached result instead of re-executing - Solution: Added
metadata_only_resultflag to track pre-step execution - Bind functions now clear this cached result via
clear_metadata_result_if_needed() step()properly re-executes with bound parameters
- Root cause:
- All 9 bind functions now call
clear_metadata_result_if_needed()before binding ensure_pg_result_for_metadata()setsmetadata_only_result = 1flagstep()clears the flag after successful execution
-
Bind functions not checking cached statement registry - Race condition for cached statements
- Root cause:
pg_find_stmt()only checked primary registry, returning NULL for cached statements - This caused bind operations on cached statements to have no mutex protection
- Solution: Use
pg_find_any_stmt()which checks BOTH primary and cached registries - Applied to all 9 bind functions for consistent thread-safety
- Root cause:
-
Auto-reset busy statements before binding
- Added
ensure_stmt_not_busy()helper to auto-reset statements that are still in-use - Prevents SQLITE_MISUSE (21) "bind on busy prepared statement" errors
- Called before every bind operation
- Added
- Deadlock when bind/reset trigger column functions - std::exception crashes
- Root cause: Non-recursive mutex caused deadlock when bind/reset internally triggered column operations
- Solution: Use
PTHREAD_MUTEX_RECURSIVEfor statement mutex - Allows same thread to re-lock mutex without deadlock
- Thread-safety race condition in reset/clear_bindings - Additional "bind on busy prepared statement" fix
- Root cause:
sqlite3_reset()andsqlite3_clear_bindings()released mutex BEFORE calling original SQLite - Solution: Hold mutex during entire
orig_sqlite3_reset()andorig_sqlite3_clear_bindings()calls - Completes thread-safety fix started in v0.8.5
- Root cause:
-
Thread-safety race condition in bind operations - "bind on busy prepared statement" errors
- Root cause: Mutex was acquired AFTER calling SQLite, not before
- Solution: Lock mutex BEFORE calling
orig_sqlite3_bind_*()in all 9 bind functions - Prevents concurrent access when Thread A is stepping while Thread B is binding
-
lastval() error causing 500 on playQueues - PostgreSQL error when no INSERT done yet
- Root cause:
sqlite3_last_insert_rowid()calledSELECT lastval()which fails if no INSERT - Solution: Gracefully return 0 (like SQLite does) instead of propagating error
- Root cause:
make macosnow auto-cleans before building to prevent corrupt object files
- std::bad_cast exceptions - SOCI ORM type conversion failures caused 500 errors
- Root cause:
column_decltype()returned NULL, causing SOCI type mismatch - Solution: Map PostgreSQL OIDs to SQLite-compatible type strings (INTEGER, REAL, TEXT, BLOB)
- Types now match what
column_type()returns, ensuring SOCI consistency
- Root cause:
- Robust C++ exception handler (Linux only):
- Per-exception-type tracking with stack traces for first occurrence of each type
- Automatic source detection: "SHIM-RELATED" vs "external C++ code"
- Library identification via
dladdr()runtime linker - C++ symbol demangling via
__cxa_demangle - Manual
/proc/self/mapsparsing (musl-compatible, no sscanf) - Throttling after 50 exceptions with type summary
- musl build script (
build_shim_musl.sh) for Alpine/musl-based containers
- Exception context tracking uses volatile globals instead of TLS (musl compatibility)
- Stack frame collection works on both ARM64 and x86_64
sqlite3_column_decltypeinterception for SOCI ORM compatibilitysqlite3_bind_parameter_indexfor named parameter support- Thread-local SQL translation cache with 512 entries per thread
ensure_pg_result_for_metadata()for pre-step metadata access- Comprehensive benchmark suite:
tests/bench_cache.c- Cache implementation comparisontests/bench_sqlite_vs_pg.py- SQLite vs PostgreSQL latencytests/bench_translation.c- SQL translation throughput
- Stack protection tests for macOS and Linux
- VERSION file for release tracking
- SQL translation now uses lock-free thread-local cache (145x speedup)
- Updated README with detailed benchmark results
- Rewrote
docs/modules.mdwith cache architecture documentation - Reorganized debug documentation into
docs/debug/
- Cached SQL translation: 0.12 µs (was 17.5 µs uncached)
- Thread-local cache is 22x faster than mutex-protected cache
- Shim overhead is <1% of total query time
- Cache lookup: 22.6 ns per operation
sqlite3_column_valuenow properly handles pre-step calls- Column metadata functions work before
sqlite3_step()is called
- SQL normalization for parameterized query caching
- Prepared statement cache with O(1) hash table lookup
- Unix socket support for PostgreSQL connections
sqlite3_expanded_sqlimplementation- Boolean value conversions for PostgreSQL 't'/'f' values
- Double-free crash in connection cleanup
- Fork safety with pthread_atfork handlers
- Stack overflow protection (multi-layer defense)
- Recursion guards with depth limiting
- OnDeck query special handling for low-stack conditions
- Loop detection for rapid repeated queries
- Stack overflow crash with 218 recursive frames
- Integer overflow in counter variables
- COLLATE NOCASE translation to ILIKE/LOWER()
- FTS4 boolean search operators (AND, OR, NOT, phrases)
- Window functions support (ROW_NUMBER, RANK, DENSE_RANK)
- WHERE 0/1 to WHERE FALSE/TRUE translation
- Improved GROUP BY expression rewriting
- Connection pooling (50 connections default, max 100)
- Query result caching with TTL-based eviction
- Thread-local connection caching
- Connection exhaustion under heavy load
- Full SQL translation pipeline
- Placeholder translation (? to $1, :name to $N)
- Function translations (iif, strftime, IFNULL, etc.)
- UPSERT translation (INSERT OR REPLACE to ON CONFLICT)
- Linux support via LD_PRELOAD
- Docker support with docker-compose
- Schema auto-initialization
- Initial release
- macOS support via DYLD_INTERPOSE + fishhook
- Basic SQLite to PostgreSQL interception
- Shadow database for SQLite-only queries