You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
POST /extractions enforced the concurrency ceiling as a check-then-act: it asked Job_Store::has_free_slot() whether the slot was free and then, as a separate step, wrote the job that takes it. Two creates arriving inside that window both read a free slot and both took it, putting two live builds on a site whose whole design says one (ADR-0004) — and the surplus job then held a slot nothing had admitted it to until it finished or the TTL sweep reclaimed it. The create path now takes the slot and re-checks it afterwards, which is the sequence the resume path has used since it was written (Dispatcher::resume_failed(), ADR-0015) rather than a second pattern invented for this: the job is persisted first, has_free_slot( 1 ) then confirms nothing else claimed the same slot in the window between, and a create that lost that race purges the job it just wrote and is refused. That release is a purge like any other, so it takes the job's own tick lock first and releases it in a finally, exactly as consume, cancel and the TTL sweep do (ADR-0019). Having handed the id to nobody does not make the losing job unreachable: it is queued, and Watchdog::patrol() reaches a queued job by enumerating Job_Store::all(), needing neither an id nor a scheduled continuation, so a patrol landing inside that same window can already be building through it — and an unlocked purge would delete that build's directory underneath it. A lock that cannot be taken is precisely that case, and the job is then left standing for the TTL sweep to reclaim once it falls silent rather than deleted out from under whatever holds the lock. The refusal is unchanged in every byte — the same 429 kntnt_extractor_too_many_jobs with the same message the check ahead of it has always sent, because what a 429 discloses about the occupied slot is a settled question this fix does not reopen. Nothing on the wire moves: API_VERSION stays 7 and honours gains no entry, since the endpoint's contract is what it always claimed to be and only the enforcement caught up with it.
An unauthenticated caller could provoke unbounded server work on POST /extractions. Its permission callback validates the whole request before it calls the Authorizer — which is deliberate, and is what lets a 404 for a resource that does not exist precede the 403 a missing capability would otherwise return first (ADR-0003) — but the route registered no args schema and no cap of any kind, so nothing bounded how many entries tables, tables_structure_only and files could carry, or how large the raw body could be. Since the Authorizer is also the first thing in that path to resolve an identity at all, the realpath() per selected file path and the two SHOW TABLES queries were spent for a caller who had presented no credentials and would be answered 401. Two caps now stand ahead of the entire ladder, capability gate included (ADR-0020): a body over max_body_bytes is refused 413 kntnt_extractor_payload_too_large before this plugin decodes it, and a combined selection over max_selection_elements is refused 422 kntnt_extractor_selection_too_large before any element of it reaches a realpath() call or the catalog. A request inside both caps is validated in exactly the order it was before, unchanged, which the suite pins by re-running every existing assertion verbatim. The body cap is narrower than it first reads, and that was measured rather than reasoned: WordPress core decodes an application/json body in WP_REST_Request::has_valid_params() before any permission_callback runs, so an oversized body that is not valid JSON is refused 400 rest_invalid_json by core and never reaches the cap, and one that is valid has already cost core's decode by the time the cap refuses it. What the cap saves is this plugin's own second decode of the same string and every check after it — the selection normalisations, the element cap, the restricted-path check, the realpath() walk and the two SHOW TABLES queries (ADR-0020's addendum). This bounds one request; it does not make pre-authorization work cheap, and it does nothing about repetition. At the default element cap a single uncredentialled request can still force on the order of 500,000 realpath() calls — minutes rather than seconds on a networked or overlay filesystem, by ADR-0014's own measurement of this project's hosts — and nothing here stops that request from being sent again immediately, or concurrently by many callers. Rate limiting is the mitigation for that, it belongs to the web server or the host, and this plugin still has no seam for it. No REST contract change; api_version stays 7.
A job killed by an unexpected throw recorded nothing about what threw, so GET /extractions/{id} reported Extractions_Controller::error_of()'s fallback sentence — The extraction failed. — as the entire account of why. That is what a null error reads as, not a message the plugin wrote. It cost a real diagnosis: a production run failed at 97.8 % of 48,559 files after six hours and left exactly that one sentence, with no stall reason and therefore none of the limit-pair reading ADR-0015 built, so the cause had to be reconstructed afterwards from four converging facts (docs/measurements/2026-08-18-production-run.md). The driver now composes a reason from the throwable it caught — its class, its own message truncated to a bound and carrying no stack trace, the file and line it came from named relative to the installation root, and the chunk that was being packaged — and writes it onto the job record in a field of its own, which the poll's error.message resolves after the plugin's own diagnosis and before the fallback, so the member still carries exactly one string and no client learns a second shape. The field is separate for a reason that has nothing to do with the poll: error means "the plugin diagnosed this itself", and Extraction_Job::is_pre_adaptation_stall() reads its nullity as the signal that identifies the one failure this release re-drives (ADR-0015). A record rebuilt from a 0.5.1-or-earlier write keeps the schema-8 budget keys absent for the rest of its life, so a throw that had filled error would have made exactly that record resumable — re-driven once, nulling the diagnosis it had just recorded and running against staging the failure had already discarded. is_pre_adaptation_stall(), Dispatcher::is_resumable() and Sweeper::reclaimable() are unchanged, and the persisted record gains one optional key whose absence is the ordinary shape of a job that has not failed that way. This does not make every failure diagnosable, and does not make a thrown failure resumable. A failure that kills the PHP process outright — an OOM kill, a worker reaped by the web server — reaches no catch block and still records nothing; the run is still terminal and its part-built container is still discarded at fail time. What it buys is the ability to tell those two apart for the first time, which is why the fallback sentence is kept unchanged rather than dressed up: a failed job carrying a recorded throwable is one PHP threw, and one still reporting the fallback is one PHP died on. No REST change; api_version stays 7.
Changed
A failed extraction's poll may now disclose a filesystem path or a fragment of SQL to its owner, and that is a decision rather than a side effect (ADR-0022). The reason a thrown failure records relays the throwable's own message, which is arbitrary by construction: a PDO or filesystem error carries a query fragment or a path, and third-party code hooked into the packaging carries anything at all. The 300-character bound on it limits the size of that disclosure and not its kind, so the rule the plugin had been stating — that a throw's message is never captured because it could carry a path or a fragment of SQL — no longer describes what the code does. It was previously widened by rewriting the sentence that stated it, in a docblock refresh nobody weighed as a decision, while the same rule stood verbatim and unchanged in a second docblock. The rule is now written down in an ADR of its own, both docblocks agree with it, and the reader it is judged against is named: the polling owner, authenticated and holding kntnt_extractor_operate plus manage_options (ADR-0002). The message is kept because a PDOException reading Table 'x' doesn't exist is often the entire diagnosis and its class alone says nothing, which is the whole reason this batch exists. What the plugin composes itself still discloses nothing it did not write: the throw's origin is named relative to the installation root, like every other path this plugin reports. The ADR scopes the rule to three sinks with three lifetimes — the poll response, the persisted job record (bounded by the ordinary TTL, because the routing fix above keeps a thrown failure out of the shape the sweep spares indefinitely), and kntnt-wp-skills, which carries the reported error into its own run output and log — and states that the audit log is not among them, since its entry has no error member and it is only ever written on the ready transition. Two ADR-0007 citations that leaned on it for a disclosure rule it does not contain now point at the new ADR; the other 25 citations of it are untouched. No REST contract change; api_version stays 7.
DEFAULT_CHUNK_SIZE, the bytes of a file packaged into one bounded part when no knob overrides it, is now 256 KB rather than 8 MiB (ADR-0023). The old figure was never measured; ADR-0015 already recorded that of 28,021 files packaged successfully on the production host the largest was 3.87 MB, so no part anywhere near the shipped default had ever been produced on that host, and the first file large enough to need one killed a run. docs/measurements/2026-08-19-chunk-size-curve.md measured the replacement: on one 36 MB file, per-part cost grows about twice as fast as the part up to 2 MiB — 256 KB packages it in 85 s, 1 MiB in 108 s, 2 MiB in 184 s, none retrying — and then something between 2 and 4 MiB breaks, at which point the same file managed two parts in twelve minutes. docs/measurements/2026-08-19-successful-run.md then carried the smallest measured value through a full production clone: 48,578 of 48,578 files and 186 of 186 tables in 3.56 h, against a six-hour run that died at 97.8 %, with pauses of 60 s or more down from 42 % of the file phase to 2.5 %. 256 KB is the only value measured to complete a real clone; it is not claimed optimal and it does not generalise. Nothing below 256 KB was tested and the curve's shape suggests smaller may be faster still, traded against a per-chunk overhead these numbers do not resolve; every figure is one host (LiteSpeed, PHP 8.4.21); and the threshold between 2 and 4 MiB is real and still unidentified. The right value is host-specific, which a shipped constant cannot be — the per-run chunk_size parameter on the create payload (#28), not this default, is what answers a host-specific value. Note what this changes for a site that was not failing: the same file is now packaged in 32× as many parts, so a host comfortable at 8 MiB does more, smaller ticks and pays this project's unattributed ~229 ms per-chunk overhead more often. Such a site raises KNTNT_EXTRACTOR_CHUNK_SIZE back, and should measure rather than guess — the method is in the curve document and took under ten minutes per point. An in-flight job that already persisted an adapted chunk_size keeps it. KNTNT_EXTRACTOR_TABLE_CHUNK_BYTES is deliberately left at 4 MiB although ADR-0013 derived it as half the file-part default: that derivation's premise is now measured false, but nothing has measured the table side, a slice costs differently from a part, and the clone that completed took all 186 of its tables at that value — so the figure stands on its own and stands unmeasured, rather than being moved by inference. No REST contract change; api_version stays 7 and nothing joins honours, since a smaller default changes no byte of the artifact's shape and is not a behaviour a caller opts into.
Added
tests/Integration/sealed-writer-test.php puts a segment name that is not valid UTF-8 through the writer and asserts it comes back byte for byte. docs/container-format.md §3 frames each index entry as an 8-byte little-endian name_length followed by that many raw bytes, and §5 states that a name carries no encoding assumption and no delimiter — the property that lets an installation-root-relative file path serve as a segment name at all, since a POSIX path is a byte string rather than text and a filesystem written under a legacy locale hands out names no UTF-8 decoder accepts. That guarantee had only ever been read off the framing: every name the suite put through the writer was plain ASCII, so a layer that quietly re-encoded names would have left the suite green. The fixture combines a Latin-1 filename, the two bytes UTF-8 never uses, and a lone continuation byte, and an ordinary name is sealed straight after it, so an index framed by a delimiter rather than by §3's length would desync on those bytes and be caught. A negative control names the mangling actually in reach — U+FFFD substitution, what PHP's JSON encoder does under JSON_INVALID_UTF8_SUBSTITUTE and what wp_json_encode() does silently — and requires it to differ from what came back, so the check would catch a sanitising writer rather than pass on a name that merely survived in recognisable shape. The property already held; no production code changed. It was verified as a real assertion rather than a permanently-green one by temporarily making add_segment() substitute the undecodable bytes, watching the round-trip assertion fail, and reverting. The coverage stops at the container layer.Job_Store::publish_json() still treats wp_json_encode() as returning false on invalid UTF-8, which it does not — it substitutes and returns a string — so a job record naming such a path is persisted mangled rather than refused; that sits above this seam and is untouched here. No REST change; api_version stays 7.
POST /extractions accepts an optional chunk_size, so the one setting that decides whether a multi-hour run survives can be chosen per run instead of by editing code on production (ADR-0021). It carries the job's file-part budget in bytes and is persisted on the job like any budget a stall adapts — which now halves from the caller's number rather than from the site's default, since a requested size is a starting point the run may walk down from and never a floor under it. The member exists because the right value is measured and host-specific: docs/measurements/2026-08-19-chunk-size-curve.md has 256 KB packaging a 36 MB file in 85 s on one production host while 4 MiB does not finish it in twelve minutes, and lowering the knob to 256 KB was the only deliberate difference between a clone that died after six hours and one that finished in 3.6 — but until now the only levers were a wp-config.php constant and a filter. The accepted range is derived, not chosen, and no constant is introduced for either end:Artifact_Builder::configured() already clamps the knob to max( 1, … ) and bounds it nowhere above, so the member accepts exactly an integer of at least one, unbounded, and anything else — zero, negative, fractional, string-wrapped — is 422 kntnt_extractor_malformed_body, the create path's existing malformed-member refusal rather than a code of its own. Omitting the member is byte-for-byte the previous behaviour. It does not choose a shipped default and does not stop a caller asking for a size the host cannot survive; an oversized request stalls, halves, and calibrates itself the way ADR-0015 built for, costing attempt windows rather than the run. chunk_size joins the honours list an authenticated GET /status reports, which is how a client tells a build that accepts the member from one that ignores it; api_version stays 7, since nothing about the sealed artifact's shape changes and an old client that never sends the member gets the identical job, record and artifact it got before (ADR-0017 as amended by ADR-0018).
Two configurable caps on POST /extractions, bounding how large one request may be (ADR-0020). KNTNT_EXTRACTOR_MAX_SELECTION_ELEMENTS (default 500,000) bounds the combined entries across tables, tables_structure_only and files; KNTNT_EXTRACTOR_MAX_BODY_BYTES (default 50 MiB) bounds the raw request body. Both are ordinary Config knobs — a wp-config.php constant or the matching kntnt_extractor_config_* filter — clamped to a floor of one, and both defaults are roughly ten times a real production selection of 186 tables and 49,116 files, which encodes to 4.47 MiB, so no ordinary clone should ever meet either. They are two knobs rather than one because they bound different costs: element count bounds the realpath() calls and catalog comparisons, body size bounds the plugin's own decode of a body that could carry few elements made of enormous strings — core has already decoded an application/json body before either cap runs, so the saving is the second decode and the ladder after it (ADR-0020's addendum). Each refusal reports the limit it was measured against and the caller's own count or bytes in the error's data, so a refused client can split or shrink its request from the one response. selection_limits joins the honours list an authenticated GET /status reports, which is how a client tells a build that enforces the caps from one that does not; api_version does not move, since nothing about the sealed artifact's shape changes and an old client is refused loudly and told the number rather than silently misled (ADR-0017 as amended by ADR-0018).
docs/measurements/2026-08-19-successful-run.md records the production clone that completed — the same site, build and selection as the run that died at 97.8 %, with chunk_size lowered to 256 KB as the only deliberate difference. 48,578 of 48,578 files and 186 of 186 tables in 3.56 h against the previous 6.08 h, pauses of 60 s or more down from 42 % of the file phase to 2.5 %, mean rate 4.00 files/s against 2.24. The controlled experiment predicted ~3.5 h and the run took 3.56. It also isolates what did not improve: the moving rate went 257 → 229 ms per chunk, which is the cost of an ordinary small file, is untouched by this setting, and is still what a run of tens of thousands of small files actually costs. No code changed.
docs/measurements/2026-08-19-chunk-size-curve.md measures chunk_size against packaging cost on the production host, holding the file, the host, the build and the method fixed so the setting is the only variable. Per-part cost grows about twice as fast as the part up to 2 MiB — 256 KB packages a 36 MB file in 85 s, 1 MiB in 108 s, 2 MiB in 184 s, none of them retrying — and then something breaks: at 4 MiB the same file managed two parts in twelve minutes, about 270 s each, which extrapolates to roughly forty minutes. The threshold between 2 and 4 MiB is real and unidentified; php://temp, whose 2 MB spill threshold would have fit, was removed by plan 006 and is structurally absent. The run also serves as the control that clears an earlier confound: a fresh single-file container at 4 MiB costs the same per part as one measured gigabytes deep into the six-hour run, so the container's state is not the driver and the part size is. Consequences: the 8 MiB DEFAULT_CHUNK_SIZE these runs were measured against is not merely suboptimal but sits far past a threshold that makes runs impossible rather than slow, and the right value is host-specific in a way no shipped constant can be. This release acts on both findings elsewhere in these notes: the default is now 256 KB (ADR-0023), and POST /extractions accepts an optional chunk_size (ADR-0021), so a site constant or filter is no longer the only lever. Adding this document changed no code.
docs/measurements/ records measured facts about how this plugin behaves on a real production host, with the raw sample series committed beside each write-up. Its first entry, 2026-08-18-production-run.md, is the measurement ADR-0014 said could not be taken from a laptop: 720 samples of a six-hour extraction against safeteam.se, taken by an observer independent of the client's own poll loop. It reports three things and states plainly what it does not establish. Three files large enough to need more than one part — 0.006 % of the selection — consumed 44 % of the wall clock, packaging seventeen times slower per byte than ordinary files, which rules out per-file path resolution and stat calls as the dominant cost. The record split's predicted factor of two on a slow host did not materialise: 257 ms per chunk of moving time against the prior release's ~210 ms on the same host, so the share of a production chunk that save() occupied was badly overestimated and the unattributed remainder is very nearly the whole cost. And the run failed opaquely at 97.8 % after six hours, recording nothing about why. ADR-0014 carries the outcome as a consequence; no code changed.