Give the Parquet byte source a vtable, ahead of object storage (#393 M1) - #446
Give the Parquet byte source a vtable, ahead of object storage (#393 M1)#446jdatcmd wants to merge 12 commits into
Conversation
…age (#393) First unit of M1. No behaviour change, and that is the property under test. Every Parquet read in this reader goes through pq_source_read, a positional read whose callers have already bounded the offset. That is the one seam a remote source has to replace, and it is why object storage touches three functions rather than the reader. Making it dispatch now, on its own, keeps the change that adds a network reviewable. PqSource gains an ops pointer and a priv slot. ops NULL means the local FILE * implementation, which is the code that was already here, renamed to pq_source_read_local and pq_source_close_local and otherwise untouched. Nothing sets ops yet. Verified by running the whole Parquet surface rather than by reading the diff: native_read_parquet, native_parquet_fdw, native_parquet_pushdown, native_parquet_projection, native_parquet_multifile, native_parquet_partition, native_parquet_streaming, native_parquet_codecs, native_parquet_hardening and fuzz_parquet, 190 checks, all green. Builds clean on 15 to 19. The decisions this is built on are recorded on #393: a separate non-preloaded module, OpenSSL for TLS inside it, ambient credentials by default, no FUSE, and v1 scoped to exact object keys so ListObjectsV2 and its XML parser get their own milestone.
ChronicallyJD
left a comment
There was a problem hiding this comment.
Reviewed. I ran the surface rather than reading the table.
Reproduced, independently, same counts as yours:
| suite | you | me |
|---|---|---|
| native_read_parquet | 12 | 12 |
| native_parquet_fdw | 14 | 14 |
| native_parquet_pushdown | 36 | 36 |
| native_parquet_projection | 15 | 15 |
| native_parquet_multifile | 32 | 32 |
| native_parquet_partition | 35 | 35 |
| native_parquet_streaming | 15 | 15 |
| native_parquet_codecs | 9 | 9 |
| native_parquet_hardening | 18 | 18 |
| fuzz_parquet | 4 | 4 |
| total | 190 | 190 |
One thing you should know about that number before you trust mine. My first three runs
reported those suites PASSED with zero checks each, because the bench had no pyarrow and
21 suites answer a missing pyarrow with echo SKIP; pgc_summary; exit 0. exit 0 is a pass.
I only caught it because I print the check count next to the verdict. The 190 above is from
after I installed pyarrow, on the same tree and the same build. Filed as #447, since it is not yours and not about this PR.
pg18a assert build, on the PR tree (9e6fdb1), premise checked in the runner that the seam
is actually present in the tree it built.
The uninitialized dispatch, which is where this pattern usually breaks
Adding a function pointer to a struct the caller allocates is normally the bug. One
PqSource src; on the stack that nobody zeroes, src->ops is garbage, and the first read
calls through it. That does not happen here, and I checked rather than assumed it.
pq_source_open already did memset(src, 0, sizeof(*src)) before this PR, at :1505. All
three allocation sites, :2916, :3095 and :3983, reach pq_source_open before any read
or close. There is no close-before-open path either. At :3095 the two ereport(ERROR)
calls sit above the open, and an ERROR does not run a close. So ops is NULL everywhere it
is read.
"No behaviour change" is proven. "The dispatch works" is not.
Nothing in the tree assigns ops. git grep finds exactly two references and both are
reads. So src->ops != NULL is false in all 190 checks, and the branch that none of them
enters is the new code.
What the suites establish is that the rename is faithful. That is worth having, and I am not
talking it down. They establish nothing about the vtable. The first thing to exercise it
will be the commit that adds a network, which is the ordering this PR exists to avoid.
It is testable now, with no network, in about ten lines:
static const PqSourceOps pq_source_ops_local = {
"file", pq_source_read_local, pq_source_close_local
};plus a developer GUC that makes pq_source_open install it. There are already 16 bool GUCs,
so the idiom exists. Then run the same suites with it on. Identical results make "behaviour
identical" a measurement instead of an argument, and every remote source later inherits a
dispatch path that has already been exercised.
The error path has no cleanup hook. Free today, not free later.
There is no PG_TRY anywhere in columnar_parquet_reader.c. I grepped for it: zero.
In the multifile loop, pq_check_row_groups (:4014), build_imp_targets (:4015) and
pq_read_rows (:4027) all sit between the open at :4013 and the close at :4030, and
all three ereport(ERROR) on malformed input. fuzz_parquet drives exactly that path on
purpose. When they throw, the close never runs.
Today the only thing leaked is the FILE *, and AllocateFile hands it back at abort. So
there is nothing to fix now and I am not asking for one. Once priv holds a socket and a
TLS context, nothing hands those back, and the leak repeats once per error on input an
attacker influences. That ownership rule is cheaper to settle in the commit that introduces
priv than to retrofit afterwards. A PG_TRY around the loop body works, so does a
resource owner callback.
None of this blocks the seam, which I think is the right one.
Second unit of M1. Establishes the packaging and the boundary; carries no protocol yet, by design, so the commit that adds a network is a network change and nothing else. The property the whole design rests on, verified on all five majors: the main library still has exactly 4 NEEDED entries and the module is a separate file depending only on libc. pgColumnar loads through shared_preload_libraries, so anything it links is mapped into the postmaster and inherited by every backend whether or not a query ever reads a remote file. libcurl alone resolves to 30 shared objects, including two TLS implementations. PostgreSQL made the same call for its own libcurl dependency: separate library, runtime dlopen behind a frozen ABI, separate package. The module is built by objstore/Makefile, because PGXS builds one MODULE_big and this must not be that one. The top-level recurses for all, install and clean, without the `-` prefix, so a failure there is not silent. Loading is on the first read of a remote path and never before, through load_external_function with error_on_fail = false. An installation without the module reports an unsupported scheme rather than failing to load, so local files are unaffected either way. The result is cached including the negative, because whether the module is installed cannot change mid-session. The ABI is versioned and a mismatch is refused rather than called through: a stale module and a new main library agree on the symbol and disagree on the struct, and that produces a wrong function through a valid-looking pointer. A remote path now reports an object-storage error from the reader. Before this it reached AllocateFile and reported "No such file or directory", which is true of the filesystem and useless. test/objstore_module.sh asserts the separation directly, because adding one object to the main OBJS list would undo it silently, and that is the failure this design exists to prevent. 12 checks. The Parquet surface is unchanged.
Second unit pushed: the module, its ABI and its loader. Still no protocol.Deliberately separate from the network commit, so that one can be reviewed as a network The property the design rests on, on all five majors: The main library has not grown a dependency and the module depends on nothing but libc.
The third check is the one that earns its place: adding a single object to the main Two decisions inside it worth disagreeing with if you do:
Next commit is the protocol: a range GET over HTTP/1.1 on a socket driven from a |
ChronicallyJD
left a comment
There was a problem hiding this comment.
The graceful-degradation claim does not hold, and the same query gives two different errors in one session
The separation property is real and I have no argument with it. The header makes a second
claim, twice, and that one I can falsify:
A build or an install without it is fully functional for local files, and reports a remote
path as unsupported rather than failing to load.
Half of that is true. Local files are completely unaffected, I checked. The error message is
not what it says.
load_external_function(..., signalNotFound = false) suppresses a missing symbol.
A missing library is raised by internal_load_library before the symbol lookup happens,
so signalNotFound never gets a say. The api == NULL branch in the reader is not reached on
a first read.
Measured on d1ed830, pg18a, by installing the module and then moving it aside:
== ARM 1: module PRESENT
ERROR: columnar: reading "s3://bucket/key.parquet" is not supported
DETAIL: Object storage support is not available in this build.
== ARM 2: module ABSENT
ERROR: could not access file "pgcolumnar_objstore": No such file or directory
== ARM 2b: local path with the module absent
INSERT 0 3 / count 3 <- "fully functional for local files" holds
The part that took me by surprise
objstore_tried = true is set before the load is attempted, and a load_external_function
failure unwinds past the assignment while the static keeps its new value. So the backend
caches "tried, got nothing" from a load that raised. The next remote read in that same session
takes the cached-NULL path and reports the documented message instead.
Two identical queries, one psql session:
module absent module present (control)
1 could not access file 1 ... is not supported
2 ... is not supported 2 ... is not supported
Same query, same session, two different errors. The control rules out anything other than the
load path. From the second read onward the documented behaviour appears, which is the worst
version of this: an operator who retries sees the message the docs promise and concludes the
first one was a blip.
What this does to your suite
objstore_module.sh would go red on an install without the module, on both URL checks:
the message matches neither object storage is not implemented nor is not supported, and it
does contain No such file or directory. That is the right alarm, but it fires on a
configuration the header describes as supported. Right now the suite only ever runs with the
module installed, so the arm that carries the claim is the one nothing exercises. That is the
same gap I raised on the first commit, and this time there is a defect inside it.
Fix, whichever way you want it
Decide which behaviour is intended, then make the code and the header agree.
- Graceful, as documented: resolve
pkglibdirwithget_pkglib_path,statthe module,
and only callload_external_functionwhen it is there. Setobjstore_triedafter the
decision rather than before, so a raising load does not leave a cached verdict behind. - Hard failure: keep the load as it is, and change the header and the reader comment to
say that a missing module is an error on a remote path. Then add the missing-module case to
the suite with that expectation, so the claim stays tested.
I have no preference between them. I do think the current state, where the first read and the
second read disagree, is the one option that is not defensible.
Smaller
Caching the negative is right for "not installed", which genuinely cannot change mid-session.
It is less obviously right for a load that failed for a reason that can change, such as
running out of file descriptors. Same static, no way to distinguish. Worth a thought when you
pick one of the two options above, not worth a change on its own.
Unchanged from my last review
abi_version is the first member of the struct, so reading it from a mismatched module is the
safe pattern rather than the usual bug. Refusing rather than tolerating a mismatch is the right
call.
…#393) @ChronicallyJD falsified the graceful-degradation claim in the header, and then found something worse underneath it. signalNotFound = false suppresses a missing SYMBOL. A missing LIBRARY is raised by internal_load_library before the symbol lookup happens, so that argument never gets a say and the caller sees 'could not access file "pgcolumnar_objstore"'. The api == NULL branch was unreachable on a first read. The load is now inside a PG_TRY, because an installation without the module is a supported configuration rather than an error. The worse half: objstore_tried was set BEFORE the attempt. The ereport unwinds past the assignment while the static keeps its new value, so the first remote read of a session reported the raw load failure and every later one reported the documented message. Two identical queries in one session, two different errors, and the second was the plausible-looking one. The flag is now set after the attempt. Proved by removal, which reproduces exactly what they measured: with the loader fix 16 checks, PASSED with it reverted FAIL neither read leaks the loader's own error: got [1] want [0] FAIL both reads report the SAME unsupported error: got [1] want [2] The coverage runs BOTH reads in one psql session on purpose. Separate sessions cannot see this, because each gets a fresh static. The header claim is corrected rather than deleted: it now says what makes the degradation graceful, which is the PG_TRY and not the signalNotFound argument. Builds clean on 15 to 19.
Both correct. The second one I would not have found, and it is the worse of the two.The first: The second: That is the part worth dwelling on. The bug hides itself after one query. Anyone Fixed and removal-proved, reproducing your measurement
The coverage runs both reads in one psql session on purpose, with a comment saying so, The header claim is corrected rather than removed: it says what actually makes the Builds clean on 15 through 19. 16 checks. TallyThree reviews from you on my work today, three real defects, and in every case the thing |
|
Both fixes are right. One consequence of the new catch, and then I am done with this commit. The reason is discarded, so "not installed" and "installed but broken" are the same message
Measured on
All three identical. And the server log: Nothing. Not at WARNING, not at LOG, nowhere. An operator who installed the module and has a This is diagnosability rather than correctness, and I would not block on it today. I am Cheap either way: PG_CATCH();
{
ErrorData *ed;
MemoryContextSwitchTo(ecxt); /* CopyErrorData needs a context that survives */
ed = CopyErrorData();
FlushErrorState();
ereport(DEBUG1,
(errmsg("columnar: object-store module not loaded: %s", ed->message)));
FreeErrorData(ed);
init = NULL;
}
Order matters: OtherwiseThe separation checks, the ABI refusal and the first-member On your tallyFair, and it cuts both ways. Every defect I have found in your work today was a written claim |
Your third finding is real. My fix for it does not work, so I have not shipped one.You are right that I tried the obvious fix: The test is right and the fix is wrong. A 19-byte file in place of the module still Rather than push a fix I cannot demonstrate, I have reverted it. The branch carries only I also notice I was about to add a test whose premise I had not asserted. The suite checked What I would like your read onWhether this belongs in M1 at all. The argument for deferring: the module does not yet do I lean towards fixing it here, properly, with the premise asserted. But it is your finding |
Your approach is sound. I think your test lied to you, and I can say why.I expected to be telling you the opposite. My theory was that
So swallow Which leaves your testYou already named it: the suite never asserted the module was broken during the run. Your
So the premise to assert is not "the module was restored afterwards". It is "at this moment, check "premise: the module is present and unloadable" \
"$(python3 -c "
import ctypes
try: ctypes.CDLL('$MOD'); print('loads')
except OSError: print('unloadable')")" "unloadable"On whether it belongs in M1Fix it here, and I do not think it is close. Your own argument against deferring is the right Reverting rather than shipping a fix you could not demonstrate was the right call. |
@ChronicallyJD's third finding, now fixed. My first attempt was wrong and this records why, because the reason is the interesting part. I discriminated on SQLSTATE: swallow ERRCODE_UNDEFINED_FILE as "not installed", re-raise anything else. Measured, that is exactly wrong. internal_load_library uses errcode_for_file_access() for BOTH the stat failure and the dlopen failure, so a missing library and "file too short" arrive with the same code: PROBE446 catch: sqlerrcode=16908805 (58P01) msg=could not load library ".../pgcolumnar_objstore.so": file too short 58P01 is ERRCODE_UNDEFINED_FILE. Swallowing on it swallowed both. So ask the filesystem. objstore_module_present() resolves $libdir with get_pkglib_path, which is exported on every major we support, and stats the file. Absent means not installed. Present means the load failed for a reason the operator has to see, and the original error is re-raised intact. The test I wrote to catch this ALSO did nothing at first, and for a reason worth keeping in the file: it corrupted the module with `printf > $MOD`, which fails silently because the module is root-owned 0755 and the suite runs as postgres, which can write the DIRECTORY but not that file. The diagnostic that found it printed the module's size, which was still 20752 rather than 19. It now moves the real module aside, CREATES a corrupt one, and ASSERTS the size is 19 before reading anything. Both premises are in the suite now: that the module is genuinely corrupt during the run, and that the real one is restored afterwards. Proved by removal. With the presence check taken out: FAIL a broken module does NOT masquerade as an unsupported scheme: got [2] want [0] FAIL and the loader's own reason survives: got [no] want [yes] 20 checks. Builds clean on 15 to 19.
Fixed. My first attempt failed for a reason I could not have guessed, and the test thatwas meant to catch it was also doing nothing.The SQLSTATE cannot tell the two cases apart. I swallowed 58P01 is Now it asks the filesystem: The test was the bigger problemMy corruption step was What found it was printing the module's size mid-run: still 20752, not 19. So my It now moves the real module aside, creates a corrupt one, and asserts the size is Removal proof, with the presence check taken out: Clean on 15 through 19. To answer my own question from earlier: it did belong in M1. The loader is the code nobody |
I was wrong, and the way I was wrong makes your fix necessary rather than merely tidyI told you "swallow Same box, same build, two dlopen failures: Both are That reconciles our two numbers without either being a mistake in measurement. It also means my The case that makes this matter is exactly the one you are about to buildCase B above is not a contrivance. A valid module whose Asking the filesystem is the right answer, and now I think it is the only correct one available. On the corruption stepYour diagnosis is better than mine. I guessed the write failed because the directory was Asserting the size mid-run is the premise that had to exist. Both of us have now shipped a test |
A fourth path, and it is the one your ABI check exists for
Measured on Identical message, nothing logged. A module that is installed, is a valid PostgreSQL module, That is precisely the stale-module case your ABI versioning is for. A module from a build where The fix is the check you already wrote, applied to the other exit: objstore_tried = true;
if (init == NULL)
{
if (objstore_module_present())
ereport(ERROR,
(errcode(ERRCODE_UNDEFINED_FUNCTION),
errmsg("columnar: object-store module is installed but exports no "
"entry point"),
errhint("Reinstall pgcolumnar_objstore from the same build as "
"pgcolumnar.")));
return NULL;
}My first attempt at this told me nothing, and I nearly reported it as a clean resultMy first stub was a bare
But my stub differed from a real module in two ways at once, and the first masked the second. Same failure as your 720-row fixture on #443 and your unbroken module here: the check ran, and |
…ove a file
CI went red on my own suite, and the premise assertion is why it was legible:
FAIL premise: the module really is corrupt for this run, not merely intended
to be: got [21592] want [19]
Two defects, and the second is the serious one.
1. The file operations went through pgc_pg, which drops to the postgres user. In
this container /usr/local/pgsql/lib happens to be writable by postgres, so it
worked. In CI the module lives in a root-owned pkglibdir and postgres cannot
move it, so the manipulation silently did nothing and the suite measured the
installed module three times. They now run as whoever the suite runs as.
2. The suite MOVES the installed module, which every other suite in the run
loads. The matrix runs PGC_JOBS suites at once, so this was mutating shared
state underneath them. Nothing broke in that run, which is luck rather than
safety: the failure it would cause appears in whichever suite happened to load
the extension at the wrong moment, and would read as a defect there. It is now
in runs_alone beside replication.
Also added: if the module cannot be moved at all, the absent and broken arms SKIP
visibly instead of failing. A suite that cannot perform its manipulation has not
found a defect, and a red gate there is about the environment.
20 checks locally. The premise that caught this stays exactly as it was.
I broke CI with my own suite, and the premise assertion is the only reason it was legibleWithout that line this would have read as three mysterious failures in the loader. With it, Two defects, and the second is the one that matters. 1. The file operations dropped to 2. The suite moves a file every other suite loads. The matrix runs Nothing broke in that run. That is luck, not safety. The failure it would cause surfaces in Also added: if the module cannot be moved at all, those arms SKIP visibly rather than 20 checks locally, premise unchanged. CI re-running. I would not have caught either of these without your insistence on premises, and the second |
|
…393) Same trap as the one this branch already fixed, one step later. The flag was set before api = init() and before the ABI check. init() is a call into a separately built library: it can raise, and with the flag already set the unwind leaves the verdict cached as "tried, nothing found". The first remote read of a session then reports init()'s error and every later one reports "requires the object-store module" -- two identical queries, one session, two different errors, the plausible-looking one second. It is latent while init() only returns a pointer to a static struct. It stops being latent in the commit that gives the module something to set up, which is the next one. The flag is now set on each return path with nothing that can raise between the assignment and its return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
…393) Three changes to the seam this branch introduced, all found by reviewing it before building the protocol on top of it. The local file path is now an implementation of the vtable rather than the fallback the dispatcher tested for. `ops` is never NULL. A sentinel left the dispatch branch executed by zero of the suite's checks, so the seam would have shipped unproven and the first thing to exercise it would have been network code. With pq_local_ops installed, every Parquet read in the suite runs through the vtable, which is what the whole commit was for. A missing module and an installed module that handles no such scheme now report separately. They ask the operator for different things -- "install a package" against "this build will never read that URL" -- and collapsing them also made the absent-module test inert: a module that handles nothing yet produced the same message as no module at all, so the arm that moved the library aside passed whether or not the move succeeded. Removing both mv lines left every check green. Measured, on this branch, before the fix. A remote path is no longer expanded against the local filesystem. pq_resolve_paths runs ahead of the byte source at every entry point, so s3://bucket/a*.parquet went to glob() locally and came back "no files match pattern" -- the filesystem-miss-for-a-remote-path report the byte source exists to remove, arriving one layer above the byte source. `*`, `?` and `[` are all legal in an S3 key, so this is an ordinary key and not a crafted one. v1 reads exact keys, so a pattern is refused rather than silently taken as a literal: expanding one needs a LIST call whose paged response is a third hand-rolled parser over input an outside party controls, which is the shape that produced #210 and #228. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
…393) The absent-module arm asserted nothing. Present and absent produced the same string, so all three of its checks passed with both mv lines deleted, and its premise ran after the restore and asserted the module was back -- it could only fail if the RESTORE failed. Now present, absent and broken are three distinct messages, each arm asserts its own and the absence of the other two, and the premise records the state DURING the reads. Proved by removal, in the container, on PG17: - both mv lines deleted -> 3 checks fail (premise, and both message counts) - pre-fix code + pre-fix suite + no move at all -> PASSED, 20 checks The separation check could not fail either. `readelf -d | grep -c` counts DT_NEEDED entries, and the failure mode it names -- someone adds the module's objects to the main OBJS list -- links them statically and emits no DT_NEEDED entry, so it read 0 either way. It also read 0 with readelf absent. It is now a symbol pair: the entry point must be absent from the preloaded library and present in the module, so a broken instrument fails the positive half. Proved by removal: with the entry point linked into pgcolumnar.so, the new check fails (got 1, want 0) and the old readelf check still reports 0. Also: pgc_require_tools for nm and stat; a trap and a refuse-to-start guard on the leftover .away, because an interrupt mid-move left this run's 19-byte stand-in installed as the module and the next run would move THAT to .away, overwriting the only real copy; the check named "a relative path" now passes a relative path; and check_num where the measurement is a number. 30 checks, PG17 and PG18. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
The module map gained no entry for the second shared library. Adds columnar_objstore.c: why object storage is a separate non-preloaded library (30 shared objects against this extension's 4, and a TLS stack in a postmaster that may have none), the frozen ABI, the two load-bearing ordering constraints in the loader, why missing and corrupt cannot be told apart by SQLSTATE, and that v1 reads exact object keys. Notes on the Parquet reader that the byte source dispatches through a vtable that is never null. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
|
Reviewed my own branch before building the protocol on it, and it needed more than the three findings already fixed. Pushed four commits. The absent-module arm asserted nothing. A module that handles no scheme yet and no module at all both produced The counterfactual, measured rather than argued:
So the loader now names the missing module separately from a scheme it cannot handle. That is a real distinction for the operator — "install a package" against "this build will never read that URL" — and it is what makes the arm capable of failing. The separation check could not fail either. The seam was never exercised. Nothing assigned A remote path was being globbed against the local filesystem. Also fixed: the cache flag was still set before 30 checks now, green on PG17 and PG18, alongside Worth stating plainly: three of the four defects above are checks that looked like they passed. That is the same pattern we have both been catching in each other's work all week, and it was sitting in mine. |
…393) test/docs_style.sh caught 9 over-long sentences and one banned idiom in the section added by the previous commit. ARCHITECTURE.md was clean on main, so every violation was mine. The limit is 25 words per sentence, and "hand-rolled" is on the idiom list. Split the long sentences rather than compressing them, and said "a third parser written here" for what the idiom was doing. No content removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
|
Attribution correction, not a technical one. The comment above at 14:23 was posted while the CLI's active account was ChronicallyJD, but it is my author-side response to the review, not the reviewer speaking. Same for the M2 spec comment on #393. Identity switched back to jdatcmd; nothing else about either comment changes. |
ChronicallyJD
left a comment
There was a problem hiding this comment.
Both fixes are in and correct. Approving.
The trap and the refuse-to-start guard are what I asked for, and the guard is better than my
suggestion because it tells the operator how to recover:
127 trap restore_module EXIT INT TERM
103 if [ -e "$MOD.away" ]; then
104 echo "FAIL $MOD.away exists, so an earlier run was interrupted mid-move."
105 echo " Restore it by hand: mv '$MOD.away' '$MOD'"
restore_module is idempotent, EXIT INT TERM covers the signal case run_all_versions had to
learn separately for its lock, and recording the corruption premise DURING the run rather than
after the restore is the fix for the thing that made your first attempt unreadable.
One residual window, not blocking
The trap is armed at 127. The writability probe moves the module at 113:
113 if ! mv "$MOD" "$MOD.probe" 2>/dev/null; then
...
118 mv "$MOD.probe" "$MOD" 2>/dev/null
120 # The module is moved aside twice below. Put it back on ANY exit ...
127 trap restore_module EXIT INT TERM
An interrupt between 113 and 118 leaves the real module at $MOD.probe with nothing at $MOD.
restore_module only knows .away, and the start guard only checks .away, so the next run
sails past the guard, finds no $MOD to move, and takes the SKIP branch at 114 — reporting that
it cannot test the absent and broken paths, on an installation that is itself broken and stays
broken until somebody notices .probe.
Two syscalls wide, so I would not hold the PR for it. But it is the same shape as the finding
this commit fixes, and your own comment at 120 says "moved aside twice below" — the probe is the
third move and it sits above the trap. Arming the trap before 113 and having restore_module
handle both suffixes closes it in two lines.
Unrelated, and it is mine not yours
Your attribution correction is worth more than it looks. The same hazard bit my side twice today:
gh reverts to jdatcmd, and I now switch and re-verify the account in a single command before
every post. If a comment of mine ever reads as though the author is arguing with themselves, it
is that, not a position change.
…393) CJD's residual finding, two syscalls wide and correct. The writability probe moves the module, and it sat above the trap: if ! mv "$MOD" "$MOD.probe" ... # move 3, unprotected ... trap restore_module EXIT INT TERM # armed here An interrupt in that window left the real module at $MOD.probe with nothing at $MOD. restore_module knew only .away, and so did the start guard, so the next run sailed past the guard, found no module to move, and reported SKIP -- on an installation that was itself broken and stayed broken until somebody noticed the .probe file. A suite reporting "cannot test the absent path" while being the reason the module is absent. Same shape as the defect this suite exists to catch, one move earlier, and my own comment said "moved aside twice below" while the probe was the third. The trap is armed before the probe and handles both suffixes; the start guard checks both. Verified: normal run 30 checks green, and a planted leftover .probe refuses to start, names the file, and prints the recovery command instead of taking the SKIP branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
|
Fixed the residual rather than merging past it — it is two lines and it is the same shape as the defect this suite was written to catch, one move earlier. My own comment said "moved aside twice below" while the probe was the third move. The trap is armed before the probe now and handles both suffixes; the start guard checks both. Verified: It refuses to start and names the recovery command, rather than taking the SKIP branch on an installation it broke itself. On the attribution note: agreed, and worth saying out loud since it cost us both time today. The |
First unit of #393 M1. @ChronicallyJD for review, and the seam is one you have opinions
about, which is why it is landing on its own before anything touches a network.
What this is
No behaviour change. That is the property under test.
PqSourcegains anopsvtable and aprivslot.ops == NULLmeans the localFILE *implementation, which is the code that was already there, renamed to
pq_source_read_localandpq_source_close_localand otherwise untouched. Nothing setsopsyet.The reason it is worth its own commit: every Parquet read goes through
pq_source_read, apositional read whose callers have already bounded the offset. That is the single seam a
remote source replaces. Landing the dispatch separately keeps the commit that adds a
network small enough to review as a network change.
Verified by running the surface, not by reading the diff
190 checks, all green. Builds clean on 15, 16, 17, 18 and 19.
The decisions this sits on, recorded on #393 today
an object-store client links reaches the postmaster
because its only argument was avoiding the preloaded-library footprint and the module
decision solves that directly
ListObjectsV2returns paginated XML and would be a third hand-rolled parser onattacker-influenced input beside Parquet and Avro. That class produced Crafted Parquet footer crashes the backend (SIGSEGV): unbounded Thrift recursion in ColumnarThriftSkip, no check_stack_depth #210 and Fuzz the Arrow IPC decoder, and fix the OOB read it found (#214) #228, so
it gets its own milestone and its own fuzz harness
What comes next, so the shape is visible
M1 rest: the module and its ABI, plus a plain-HTTP range GET against a local
Range-capable server. Measurable against the memo's prediction of 521 naive requests versus
7 with chunk-granular buffering on a 20 MB file.
M2: SigV4, which needs no new dependency.
pg_cryptohash_*andpg_hmac_*are exportedon all five majors and work without OpenSSL; proved by building a module against each.
M3: TLS. The milestone with a real defect path and no oracle, so
X509_check_hostplus
X509_check_ip,X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS, SNI suppressed for IPliterals, each proved by removal against wrong-host, expired, self-signed and IP-SAN
certificates.
M4: credentials. Independent of the rest and can land first if convenient.
What I would attack in review
Whether the vtable is at the right level. I put it on
PqSourcebecause that is where thethree functions already are, but an alternative is to dispatch inside
pq_source_openonlyand keep one struct per scheme. If you would rather have the latter, now is the cheap time
to say so.