Summary
While reviewing PR #317, we found and fixed a real bug in foreign_expr_walker's T_ScalarArrayOpExpr case (commit fcc4ad3): a lsecond(oe->args) read ran before the optype == CHFDW_OP_NONE guard that's supposed to establish oe->args is well-formed, instead of after. That was a deterministic hang/corruption bug and is fixed.
While verifying that fix, we hit a second, separate, and much stranger symptom that this issue tracks. It does not block PR #317 and we've stopped actively investigating it, but it's real, reproducible under specific conditions, and worth a paper trail in case it resurfaces.
Symptom
Querying a ClickHouse foreign table (binary driver) with a Nullable(Int32) column and an Array(Int32) column produces:
ERROR: cache lookup failed for type 0
This is Postgres core's own generic, unprefixed error for passing InvalidOid to a pg_type syscache lookup — not one of our own pg_clickhouse:-prefixed errors. It's a normal catchable ERROR, not a crash. It reproduces even for a trivial query with no WHERE clause at all, e.g. SELECT id FROM tnull; where id is a plain NOT NULL Int32 column. EXPLAIN VERBOSE shows the deparsed remote SQL is unremarkable; the error happens at row-decode time, not planning time.
What we ruled out
- Bisected to commit
713f46d ("Delete saop_null_semantics_ok, extract op_expr_never_null"), and reproduces on that commit alone — every ancestor commit on this branch, the merge commit right before it, and origin/main are all clean against the identical repro.
713f46d contains no second logic defect. An independent review (stripping comments and diffing code-only) mechanically confirmed the commit, with the ordering fix applied, is semantically identical to its parent.
- Not a vendor/ClickHouse-C issue.
src/binary/*.c (the actual binary-protocol decode/convert code) is byte-identical between main and this branch across the whole range; only src/deparse.c differs.
- Not a thread-safety/static-init issue. The bug reproduced even on bare
713f46d, before is_non_nullable_op's bsearch/lazy-init pattern existed at all (the original inline linear scan had no such pattern). Swapping between the lazy-bool-guard version and a load-time-constructor version made no difference either way.
What made it disappear
Every diagnostic tool we pointed at it made the bug vanish, on the one platform where it reproduces at all (see below):
- A read-only
elog(WARNING, ...) probe placed at the suspected pglink.c read site, printing the relevant Oid values, made the error stop reproducing — and every printed value was correct.
- Running the full postmaster (all forked backends) under
valgrind --track-origins=yes made it disappear too; every finding across ~80 backend logs was an unrelated, well-documented benign false positive in RelationCacheInitializePhase3.
- An attempt to build with
-fsanitize=address,undefined and LD_PRELOAD the runtime got the postmaster killed before it could even test anything, likely ASan's shadow-memory scheme fighting the qemu-x86_64-on-arm64 emulation this environment runs under.
- MemorySanitizer (the sanitizer that actually catches uninitialized reads) turned out to need the entire process statically linked with it (
undefined symbol: __msan_retval_tls when dlopening an MSan-built .so into a vanilla postgres) — not attempted further, since:
Platform dependence
- Reproduces reliably (every run) on x86_64 under qemu emulation (this environment's only available x86_64).
- Does not reproduce at all — not once, uninstrumented, plain build — on native arm64 (a from-scratch Debian PG18 install on the same physical host, no qemu).
- We have not tested real (non-emulated) x86_64 hardware.
Conclusion
Consistent with a genuine, pre-existing (not introduced by this PR) uninitialized-memory read somewhere in the shared execution path, whose visible failure mode depends on the specific garbage byte pattern left in an Oid-sized slot — a pattern that is itself sensitive to ABI/stack-layout details that differ between architectures, and apparently between instrumented and uninstrumented builds of the same architecture.
We've only ever reproduced this with trivial queries that don't affect real results, only in a single emulated environment, and the behavior disappears the moment any sanitizer or diagnostic tool is attached within that same environment. Given how resistant it's been to every tool available here, we're stopping the investigation rather than continuing to chase it. If it resurfaces — especially on real (non-emulated) x86_64 hardware, or via a full from-source Postgres + MemorySanitizer build — this issue has the repro and everything already ruled out.
Repro
CREATE SERVER in_null_svr FOREIGN DATA WRAPPER clickhouse_fdw OPTIONS(dbname 'in_null_test', driver 'binary');
CREATE USER MAPPING FOR CURRENT_USER SERVER in_null_svr;
SELECT clickhouse_raw_query('DROP DATABASE IF EXISTS in_null_test');
SELECT clickhouse_raw_query('CREATE DATABASE in_null_test');
SELECT clickhouse_raw_query('CREATE TABLE in_null_test.tnull (id Int32, xn Nullable(Int32), xp Int32, arr Array(Int32)) ENGINE = MergeTree ORDER BY id');
SELECT clickhouse_raw_query($$INSERT INTO in_null_test.tnull VALUES (1, 1, 1, [1, 500]), (2, 2, 2, [500]), (3, NULL, 3, [])$$);
CREATE SCHEMA in_null_test;
IMPORT FOREIGN SCHEMA in_null_test FROM SERVER in_null_svr INTO in_null_test;
SET SESSION search_path = in_null_test, public;
SELECT id FROM tnull;
Summary
While reviewing PR #317, we found and fixed a real bug in
foreign_expr_walker'sT_ScalarArrayOpExprcase (commitfcc4ad3): alsecond(oe->args)read ran before theoptype == CHFDW_OP_NONEguard that's supposed to establishoe->argsis well-formed, instead of after. That was a deterministic hang/corruption bug and is fixed.While verifying that fix, we hit a second, separate, and much stranger symptom that this issue tracks. It does not block PR #317 and we've stopped actively investigating it, but it's real, reproducible under specific conditions, and worth a paper trail in case it resurfaces.
Symptom
Querying a ClickHouse foreign table (binary driver) with a
Nullable(Int32)column and anArray(Int32)column produces:This is Postgres core's own generic, unprefixed error for passing
InvalidOidto apg_typesyscache lookup — not one of our ownpg_clickhouse:-prefixed errors. It's a normal catchableERROR, not a crash. It reproduces even for a trivial query with noWHEREclause at all, e.g.SELECT id FROM tnull;whereidis a plainNOT NULL Int32column.EXPLAIN VERBOSEshows the deparsed remote SQL is unremarkable; the error happens at row-decode time, not planning time.What we ruled out
713f46d("Deletesaop_null_semantics_ok, extractop_expr_never_null"), and reproduces on that commit alone — every ancestor commit on this branch, the merge commit right before it, andorigin/mainare all clean against the identical repro.713f46dcontains no second logic defect. An independent review (stripping comments and diffing code-only) mechanically confirmed the commit, with the ordering fix applied, is semantically identical to its parent.src/binary/*.c(the actual binary-protocol decode/convert code) is byte-identical betweenmainand this branch across the whole range; onlysrc/deparse.cdiffers.713f46d, beforeis_non_nullable_op'sbsearch/lazy-init pattern existed at all (the original inline linear scan had no such pattern). Swapping between the lazy-bool-guard version and a load-time-constructor version made no difference either way.What made it disappear
Every diagnostic tool we pointed at it made the bug vanish, on the one platform where it reproduces at all (see below):
elog(WARNING, ...)probe placed at the suspectedpglink.cread site, printing the relevantOidvalues, made the error stop reproducing — and every printed value was correct.valgrind --track-origins=yesmade it disappear too; every finding across ~80 backend logs was an unrelated, well-documented benign false positive inRelationCacheInitializePhase3.-fsanitize=address,undefinedandLD_PRELOADthe runtime got the postmaster killed before it could even test anything, likely ASan's shadow-memory scheme fighting the qemu-x86_64-on-arm64 emulation this environment runs under.undefined symbol: __msan_retval_tlswhendlopening an MSan-built.sointo a vanilla postgres) — not attempted further, since:Platform dependence
Conclusion
Consistent with a genuine, pre-existing (not introduced by this PR) uninitialized-memory read somewhere in the shared execution path, whose visible failure mode depends on the specific garbage byte pattern left in an
Oid-sized slot — a pattern that is itself sensitive to ABI/stack-layout details that differ between architectures, and apparently between instrumented and uninstrumented builds of the same architecture.We've only ever reproduced this with trivial queries that don't affect real results, only in a single emulated environment, and the behavior disappears the moment any sanitizer or diagnostic tool is attached within that same environment. Given how resistant it's been to every tool available here, we're stopping the investigation rather than continuing to chase it. If it resurfaces — especially on real (non-emulated) x86_64 hardware, or via a full from-source Postgres + MemorySanitizer build — this issue has the repro and everything already ruled out.
Repro