Skip to content

Releases: Kntnt/kntnt-extractor

v0.8.0

Choose a tag to compare

@TBarregren TBarregren released this 23 Aug 11:47

Changed

  • Re-wrapped standalone prose comments throughout classes/ to keep every physical line within column 80 without changing comment meaning or executable tokens, removed vertical alignment from PHPDoc parameter columns, and disabled the conflicting Squiz parameter-spacing requirements in PHPCS (#59, #60).

Fixed

  • The restricted-path deny-list now covers the editor-dropping and modern OpenSSH key shapes ADR-0011 already claims, and checks both a selected path's submitted spelling and its resolved identity at create and packaging time (#37, ADR-0029). An innocuously named symlink can therefore no longer package wp-config.php, and a file replaced after validation fails hard rather than becoming a skip. api_version stays 7: this is the existing loud refusal applied more completely, not an artifact-contract change or an undetectable client misinterpretation.
  • ADR-0026's open client check is answered: the installed client freezes the 201 response's skipped_files, so a mid-run skip makes its strict unseal fail after downloading the completed extraction. api_version nevertheless stays 7 because no already-shipped client population needs ADR-0018's interlock; Kntnt/kntnt-wp-skills#78 is the client-side fix that prevents the failure and must be installed before this widened-strict build reaches production (ADR-0030).
  • strict: false now covers a file that vanishes during packaging, not only one that was already gone when the job was created (#31, ADR-0026, and a dated addendum on ADR-0003). The mitigation was applied once, at create time, so the gap it closed was the one between the GET /files walk and the POST — while the much larger gap, between the POST and the last chunk, stayed fatal. That is the gap that ended a production run at 97.8 % after six hours, with 186 of 186 tables and 47,504 of 48,559 files already packaged and every byte of it discarded at fail time (docs/measurements/2026-08-18-production-run.md §3). A strict: false job now drops a file that is gone when its chunk is packaged, appends it to the same skipped_files the create reports, and carries on; strict: true is unchanged, an out-of-bounds path is still never a skip, and a file that exists but cannot be read is still an error in both modes. A file that vanishes between two of its own parts also still fails the job, deliberately: its earlier bytes are already in the container, and skipping there would publish a silently truncated file that docs/container-format.md gives a reader no way to detect. What this does not fix: it does not make the failed run recoverable, it does not stop a file from vanishing, and — the one that matters to a caller — it does not make a vanished file's absence harmless to the copy. The file is still missing from the artifact; the caller is still told which, and must still act on it. This widens where the strict: false contract applies, not what it guarantees. It also leaves the per-part cost that made that run six hours long (§1 of the same measurement) untouched.
  • Status_Controller::HONOURED_BEHAVIOURS gains packaging_skips, so a caller can tell a build whose skip reaches the whole run from one whose skip stops at create time. strict and skipped_files were both already on the list against a build where a mid-run deletion still killed the job, which is the strict discoverability failure ADR-0017 exists to close, one layer up. API_VERSION stays 7 and the job record's SCHEMA_VERSION stays 8.

Added

  • A packaging tick can time the phases it spends its wall clock in (#39), so the ~229 ms an ordinary small file costs per chunk on the measured production host can be attributed rather than bounded. Across ~48,500 files that is about three hours, and since #27 lowered the file-part default it is very nearly the whole run — yet nothing had ever said where it goes, because the build could not see inside a tick. Define KNTNT_EXTRACTOR_PHASE_TIMING (or return true from kntnt_extractor_config_phase_timing) and every completed chunk records, separately, the container's open or resume, the path resolution and its stat calls, the source file's open-read-close, the seal, the container's suspend, and the record-split save — with the chunk's own total as the denominator the unattributed remainder is read off. The separation is the point: the leading hypothesis is filesystem latency, and a single per-chunk number can neither confirm that nor kill it. It is off by default and a run that did not ask for it reads no clock at all — no timer is constructed, and the job record is byte for byte what it was. The series is the attempt log's shape (ADR-0016): the newest eight chunks, on state.json and projected onto GET /extractions/{id} as timings?, so it never grows with the selection, and it holds no path, no SQL and no secret. Timing is a debug surface rather than part of the artifact contract, so api_version does not move and the behaviour is named in honours as timings instead (ADR-0017). It measures and changes nothing else: what the numbers turn out to mean, and what to do about it, is #30's.
  • A golden-artifact check on the sealed container's byte-compatibility claim (ADR-0025). ADR-0014 asserts that an artifact produced by this release is byte-compatible with one produced by 0.5.1, and docs/container-format.md recorded that nothing here could check the sentence. composer gate now does: tests/Fixtures/container-0.5.1.b64 is a container the 0.5.1 writer produced, tests/Support/Sealed_Reader.php is a reader of the format written from docs/container-format.md alone and never shipped, and tests/Integration/golden-container-test.php opens the fixture with it, drives today's writer over the same recipe, and compares the two. What it compares is the framing — the version byte, the total length, the index length and the ordered sealed-key and ciphertext lengths, which is every byte of a container that is not a sealed key, a nonce or a ciphertext — and it also asserts the two are not the same bytes, because those three spans are random and must differ. §5's reassembly rule and five reader refusals are pinned besides. The fixture's bytes are never regenerated: every expected number is computed from the recipe by the specification's own arithmetic, no digest of the fixture is recorded anywhere, and no regeneration script is committed, so re-cutting the blob cannot turn a red test green — it can only move which assertion is red. A red golden test means the code stopped honouring the format, or the format moved deliberately and owes a second fixture. No production code changed, not a line and not a comment. No REST change.
  • docs/release-procedure.md §9 makes a site's pinned chunk_size override visible before a release is installed on it, and §8's install sequence runs it at step 2. The step exists because this project's only completed clone depended on a value the plugin did not ship: 256 KB, held on production as a filter on kntnt_extractor_config_chunk_size inside a Perfmatters code-snippet collection, materialised as an mu-plugin — a place no file search of the site's plugins reaches and nothing in this repository knew about. Now that ADR-0023 ships that same 256 KB, such a pin is a second copy of the number that will silently override any future change to the default, so the step has an override matching the shipped default removed and one deviating from it justified in writing or removed. It names both hiding places — a wp-config.php define and a filter, the latter possibly in a snippet manager's collection — and directs the reader to the part count rather than to any configuration report: the config seam is constant-then-filter, so a snippet registered after the filter has fired reads as configured while not being in force, and the 36 MB file docs/measurements/2026-08-19-chunk-size-curve.md was measured on is 139 parts at 256 KB against 9 at 4 MiB. Reporting the resolved chunk size over REST was considered for this and rejected on that ground (#32). No REST change; no code change.
  • ADR-0024 records that this plugin carries no backwards compatibility with its own earlier releases below 1.0 — no migration, no tolerance branch for a record an older release wrote, no cleanup routine for what one left behind, and no deprecation cycle. The ground for it is a fact ADR-0015 assumed the opposite of: the plugin is installed on exactly one site, operated by the author, and distributed to nobody else, so the retirement condition ADR-0015 called uncheckable "because it ships to sites the author does not operate" is decidable after all. This does not touch the sealed container's byte format or the REST contract, whose lifecycles ADR-0017 and ADR-0018 govern and whose coordinated-release requirement with kntnt-wp-skills rests on a different fact — that the client is a separate program with its own cadence, so an installed copy can be older than the server it is pointed at. Compatibility with a concurrently-running client is a real constraint; compatibility with a record this plugin itself wrote three releases ago is not. The decision is revisited on reaching 1.0, and must be revisited before the plugin is ever installed anywhere the author does not operate, since a population of one is its whole ground.

Changed

  • create()'s runaway docblock line is wrapped and its lost-race branch comment breathes (#48). create()'s docblock was wrapped throughout except for one line of 113 columns — a sentence appended to a wrapped paragraph without re-wrapping it — and the lost-race branch put its fourteen-line paragraph comment straight onto the opening {, while the return below it kept the blank line its sibling paragraph is entitled to. Both break agents.d/coding-standard/general.md, which calls the paragraphing rule "the most central in this standard", and composer gate said nothing...
Read more

v0.7.0

Choose a tag to compare

@TBarregren TBarregren released this 21 Aug 10:47

Fixed

  • 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 ...
Read more

v0.6.0

Choose a tag to compare

@TBarregren TBarregren released this 16 Aug 20:03

The largest release so far, and a coordinated one: api_version moves from 6 to 7, so this build must not be pointed at by a kntnt-wp-skills older than 0.10.0. Install the plugin first, then the client, and never while an extraction is in flight — GET /extractions must list nothing before you upgrade.

Four things dominate it. A large extraction spent 96 % of its per-chunk time rewriting its own job record and got slower the further it went; the record is now split on what is unbounded, and per-chunk cost no longer varies with how much was selected. A stall no longer fails a job while any bound it spends can still shrink — it halves, persists, and continues. GET /environment stopped returning every non-core wp-config define in plaintext, which is how a third-party API key left a client's server; values now disclose from an allow-list, with a per-record disclosure discriminator so a caller can never confuse a withheld value with a genuinely null one. And a failed or abandoned run now cleans up after itself instead of accumulating one working directory per failure until uninstall.

Two smaller additions are worth knowing about because they are how you discover the rest: an authenticated GET /status now carries honours, naming the caller-visible behaviours this build implements, and GET /extractions/{id} carries attempts, the last eight packaging chunks a job has begun — which is what answers "what was attempted" on a long or stuck run.

Known limits, stated rather than glossed: the orphan-artifact walk is bounded per cycle and restarts from the beginning, so on a large downloads directory an entry beyond the bound is not guaranteed to be reached in any one cycle. The sealed container's framing still carries no MAC of its own — each segment is individually authenticated, and docs/container-format.md §7 states what a reader must therefore do. Over half of the production per-chunk cost this release did not explain remains unattributed and needs a real host to settle.

Fixed

  • POST /extractions/{id}/consume and DELETE /extractions/{id} (cancel) deleted a job's artifact and working directory without taking the per-job tick lock every other purging actor (the internal tick driver, the TTL sweep) already took, even though Job_Store::purge()'s own docblock named consume, cancel, and the sweep as its three callers. A cancel landing mid-tick could delete the directory a live build was still writing into, and if the driver's artifact-publish rename won that race, a sealed artifact landed in the served downloads directory for a job whose record had just been deleted — reachable by no id and invisible to the sweep, which walks job records and had none left for it (ADR-0019). Both routes now take the same lock, with the same try/finally discipline the sweep already uses; a lock a live tick is holding answers 409 kntnt_extractor_locked rather than a silent skip or a blocking wait, and the caller simply retries. api_version stays 7.
  • Nothing ever reclaimed a sealed artifact left behind with no job record at all — the shape the lock fix above closes going forward, but not what an unfixed build, or a crash between an artifact's publish and its own record settling, may already have left on disk. Job_Store::orphaned_artifacts() enumerates the served downloads directory for exactly that residue, pinned the same way every other destructive path in the store is pinned (a null-byte guard, the artifact's own filename shape, resolved strictly inside the downloads directory, never a followed symlink), and bounded per call so a directory with many files cannot make one sweep cycle's disk work unbounded. Sweeper::sweep() now reclaims what it finds, once an artifact has aged past its own grace period — the resolved TTL, reused rather than an invented constant, because it is already the window a never-consumed but still-recorded artifact is judged by. An artifact inside that window is left untouched, which is the assertion that matters most: it is what stops this fix from eating a live job's output. api_version stays 7.
  • GET /environment disclosed a wp-config define's value from a deny-list of six shapes (four exact core-secret names, any *_SALT suffix, any NONCE_* prefix), so every third-party credential a site happened to define — an SMTP password, a signing secret, an API key, a licence key, a secondary database credential — came back in plaintext to any caller who cleared the endpoint's capability gate. This is not theoretical: a real extraction run against a client site carried a 40-character third-party API key (KNTNT_PAPAPI_KEY) off the server this way, matched by none of the six deny-list shapes, and it sat in a local scratchpad for three days (ADR-0018). A define's value now discloses only from a curated allow-list of layout and behaviour facts (ABSPATH, WP_CONTENT_DIR, DB_NAME, WP_DEBUG, and similar) plus a heuristic backstop that withholds anything shaped like a credential (a name containing KEY, SECRET, TOKEN, PASS, SALT, NONCE, AUTH, CREDENTIAL, PRIVATE, LICEN, or API), applied after the allow-list and never overridden by it. Define names are unchanged and still always reported; only values are gated. Each defines record now also carries disclosure (included, secret, or not_allow_listed), present on every record including disclosed ones, so a caller can never confuse a value withheld by policy with a value that is genuinely null — see docs/define-disclosure.md. A site operator can opt a specific unlisted define in explicitly, per site, through the new KNTNT_EXTRACTOR_DISCLOSABLE_DEFINES constant or kntnt_extractor_config_disclosable_defines filter.
  • with_resume() forwarded $budget_keys_present from the stranded record, so a pre-adaptation resume saved without the schema-8 budget keys. The next tick reloaded the Config defaults and is_pre_adaptation_stall() still matched — an infinite resume, and the sweep kept sparing it. Resume (and with_budgets()) now stamp the keys present, which is the moment adapted sizes are written onto a record that never had them. No REST change.
  • A stall reason written by a later tick reported that tick's live memory_limit and max_execution_time rather than the pair in force when the chunk died (ADR-0015). Dispatcher::$host_limits held the pre-raise reading for the current PHP process only, so a run that adapted over hours described the wrong kill. The first tick now persists the pre-raise and post-raise pair on the job, and stall_reason() reads it from the record. A schema-8 record written before those fields still parses. No REST change.
  • is_pre_adaptation_stall() treated any diagnosed failure whose budgets were still zero as the 0.5.1 resume. This release always writes the schema-8 budget keys (as 0 until a stall shrinks them), so a structure-only or index stall — failed, diagnosed, keys present at 0 — was classified as that resume and the TTL sweep spared it forever. The predicate now means what ADR-0015 already said: failed, diagnosed, and the budget keys absent from the record. A this-release write that has the keys at 0 is ordinary residue. No REST change.
  • A failed job left its part-built container on disk, invisible to the stranded-job sweep because GET /extractions lists only non-terminal jobs. A job this release fails is never resumable, so the container and its index sidecar are now deleted at the moment of failure; the record stays so a poll still reports failed with its reason. A stall recorded by 0.5.1 or earlier still keeps its staging — that is the resume. No REST change.
  • Nothing ever reclaimed a failed job's record, so a site accumulated one working directory per failed run until the plugin was uninstalled — and after the record split each of those carries the whole selection in job.json, which is megabytes on a large one. failed was skipped by the TTL sweep along with every other terminal state, but it is the only one that leaves anything behind: consume, cancel, and the sweep itself all purge the directory at the transition. The sweep now applies its usual two windows — the heartbeat TTL and the absolute lifetime ceiling — to a failed record too, so a failure is residue for one TTL rather than forever, and a later poll of a reclaimed job is the same 404 any other purged job answers with. The stranded pre-adaptation stall is spared however old, because that record is what the resume path re-drives; Extraction_Job::is_pre_adaptation_stall() is now the one definition of that shape, read by both the resume and the sweep, so the two can never disagree about which container is still needed. No REST change.
  • A failed extraction could not be resumed, so a run an earlier release stranded cost the whole thing (ADR-0015). The state needed to continue already existed — Build_Progress, the append-only container, Sealed_Writer's truncate-on-resume — and what was missing was permission. A job stalled and failed by 0.5.1 or earlier, which had no way to adapt and so died at the first wall, is now re-driven from its persisted progress by the next tick or the watchdog: the container is truncated to the last acknowledged offset so an uncommitted tail cannot be duplicated, the spent attempt counter is reset, and the job re-enters running with smaller budgets. Upgrading over a dead run recovers it instead of restarting it. Two failures are deliberately never re-driven. An opaque failure (an unexpected throw, which records no reason) would retry a permanent error forever. And a stall this release recorded is by construction one it already shrank its way to the floor over, so re-driving it would re-run a search whose every remaining step has been tried — which is also why the resume condition is the absence of the schema-8 budget keys rather than the presence of a diagnosed stall: the latter reads more generous and would in fact be unsatisfi...
Read more

v0.5.1

Choose a tag to compare

@TBarregren TBarregren released this 14 Aug 13:29

A follow-up to 0.5.0: the new chunk counter reported one chunk too few on a finished job, and the byte-bounded slicing 0.5.0 introduced was only proven on one of the three primary-key shapes it has to handle. Both are settled here.

Fixed

  • progress.chunks_done now counts every packaging chunk on a ready job, the finalizing one included. It used to read one short of the artifact's segment count, because the call that seals a selection's last segment also finalizes and publishes the container and reported only "the build is complete" — so the segment it had just sealed was never recorded. 0.5.0 shipped that as a documented off-by-one in three places; it is now simply correct, and Artifact_Builder::advance() answers with a Build_Step that carries the progress and the completion flag separately rather than conflating them. The REST API version is unchanged: chunks_done has no total and is documented as a liveness signal rather than a completion ratio, so its terminal value was an implementation artifact, not a promise a caller could build on.

Changed

  • The chunked table dump is now covered on every primary-key shape under a byte budget that cuts inside a page, not just the single-column one: a composite cursor is a tuple taken from the last rendered row and a keyless table resumes on the rendered row count, so a byte cut that mis-set either would skip or duplicate rows silently. It does neither — proven rather than assumed, on all three shapes.

Full changelog: https://github.com/Kntnt/kntnt-extractor/blob/v0.5.1/CHANGELOG.md

v0.5.0

Choose a tag to compare

@TBarregren TBarregren released this 14 Aug 12:28

A table of few very fat rows could not be packaged at all: the slice budget counted rows, and rows are not what a host kills a request over. A slice is now bounded by bytes as well, and the poll gained a counter that moves on every chunk so a slow job is distinguishable from a stuck one.

Added

  • A table slice is now bounded by bytes as well as by rows, through the new table_chunk_bytes knob (KNTNT_EXTRACTOR_TABLE_CHUNK_BYTES or its filter, default 4 MiB), and ends at whichever bound it reaches first (ADR-0013). The default is deliberately half the file-part KNTNT_EXTRACTOR_CHUNK_SIZE: a host that packages 8 MiB file parts without complaint demonstrably survives that much segment payload, and a table slice costs more per byte than a file part, since it is fetched as PHP row arrays, escaped into SQL, and copied again through the seal. A table of ordinary rows never reaches the byte bound — the row budget is spent long first — so nothing that already worked packages differently.
  • The poll's progress object carries a fifth counter, chunks_done: packaging chunks committed so far — one table slice, one structure-only table, or one file part. The four existing counters advance only when a whole table or a whole file finishes, so a job working steadily through one large table reports exactly what a wedged job reports; a client watching this one can tell slow from stuck. It carries no total, because how many slices a table takes is not knowable before it is dumped, and it counts committed chunks, so a ready job reads one short of the artifact's segment count — the final chunk publishes the container rather than persisting progress. Watch chunks_done for liveness and the other four for completion.

Changed

  • Bumped the REST API version to 6 for the poll response's new chunks_done counter. The change is additive — every existing field keeps its meaning — but the poll response is caller-visible and ships under a bump like any other change to it.
  • A slice cut short by the byte budget groups its rows into INSERT statements differently than the same table dumped in one call would, so the reassembled SQL is equivalent to but not byte-identical with an unsliced dump. Every slice still ends on a complete statement, which is the only property reassembly ever needed; a consumer that concatenates a name's segments in index order is unaffected. Cutting on whole statement batches instead was considered and rejected: it would preserve byte-identity, but a single batch of a hundred fat rows can overshoot the byte budget many times over, which is the failure the bound exists to prevent.
  • The stall failure reason names KNTNT_EXTRACTOR_TABLE_CHUNK_BYTES first, ahead of the row knob, since it is now the one that most often wants lowering.
  • No behaviour change, but worth knowing if you have wondered: a job that reaches failed appends no audit entry. The record is written at ready because that is the instant an artifact becomes downloadable (ADR-0004/0006), so a run that stalled and failed is absent by design rather than dropped — which is how it read when a production log carried the small jobs around a failed extraction and nothing for the extraction itself. The absence is now an acceptance criterion in the suite and a note on ADR-0006 instead of an unstated consequence of where the hook sits.

Fixed

  • A table of few very fat rows no longer stalls an extraction (ADR-0013). The slice budget was a row count alone, on the reasoning that a site with unusually fat rows would lower the knob rather than discover the host's ceiling by being killed at it. A site discovered it by being killed at it: on production, wp_rcb_template726 rows, 16.31 MB, ~23 KB per row — sat far inside the 1,000-row default, was therefore taken in a single slice, and never finished one, failing the job after three attempts that each died where no catch could see them. The contrast in the same run shows the unit itself was wrong rather than its value: wp_postmeta, at 100,890 rows and 493 MB, went through in about a hundred slices without trouble, because a thousand narrow rows is a small slice. It is not a table's size that decides whether a slice fits, it is its rows'. Which of the host's two limits broke is not known — the plugin reports both and can observe neither killing it — so the default byte budget carries a margin rather than a measurement.
  • A page of rows cut short by the new byte budget is no longer mistaken for the end of the table. Completeness was decided on a short page alone, which was sound while rows were the only bound and becomes ambiguous the moment a page can be short for a second reason; left unchanged it would have ended a table mid-row-set and sealed a silently truncated dump that reloads without a single error — worse than the loud stall it replaces, because nothing downstream would ever say so. A table is now complete only when every fetched row was rendered and the page came back shorter than asked for, and the resume cursor is the last row rendered, never the last one fetched.

Full changelog: https://github.com/Kntnt/kntnt-extractor/blob/v0.5.0/CHANGELOG.md

v0.4.0

Choose a tag to compare

@TBarregren TBarregren released this 13 Aug 17:33

Added

  • GET /status answers the identity question directly (ADR-0012). When a request's credentials resolve to a WordPress user, the response carries authenticated_as (that user's user_login) and capabilities (a map of kntnt_extractor_operate and manage_options to whether the user holds it) alongside the API version. An anonymous request receives byte-for-byte the handshake it always did, so the version check stays reachable without credentials.

Changed

  • A selected table is now packaged in bounded slices of rows across as many ticks as it takes, exactly as an oversized file is packaged in bounded parts (ADR-0013). Each slice is its own sealed segment recorded in the sealed index under the table's own name, so a reader must concatenate every segment carrying a name, in index order, to reassemble a table — the rule file parts have always required. A reader that expected exactly one segment per table, or that kept only the last segment it saw for a given name, will silently lose all but a table's final slice. Breaking for such a reader. The rows of a slice are read by keyset pagination on the table's primary key, using the whole key so a composite one (WordPress ships one on wp_term_relationships) is paged like any other; a table with no primary key at all falls back to an offset walk. Rows of a keyed table are therefore emitted in primary-key order rather than in whatever order the engine chose, so a dump reloads identically but is not byte-for-byte comparable with one from an earlier release. The slice size is the new table_chunk_rows knob (KNTNT_EXTRACTOR_TABLE_CHUNK_ROWS or its filter, default 1000 rows).
  • Bumped the REST API version to 5 for the artifact's new segment shape above.
  • The shared authorization gate names each refusal instead of returning one opaque code (ADR-0012). A request that resolved to no WordPress user is now 401 kntnt_extractor_not_authenticated; an authenticated caller missing a capability is 403 kntnt_extractor_missing_operate_capability or 403 kntnt_extractor_missing_manage_capability. This reverses the earlier choice to answer an anonymous caller 403 on the grounds that it is definitionally missing the Operate capability: the two failures have different remedies — send credentials versus grant a capability — and a code that cannot separate them is what made a stripped Authorization header, an unknown username, a revoked capability, and a cached error response indistinguishable. GET /audit-log uses the same two codes for its own manage_options gate. Breaking for a client that tested for 403 to mean "not permitted".
  • Bumped the REST API version to 4 for the coordinated pair above: the status endpoint's new members and the gate's new refusal codes are one caller-visible contract change.

Fixed

  • A table larger than what one PHP request can carry no longer makes an extraction impossible, and no longer hangs forever pretending to run (ADR-0013). A table used to be packaged whole, in a single tick, from one unbounded SELECT * that materialised every row in memory; on a host whose memory_limit or max_execution_time that exceeded, the tick was killed outright — neither limit raises anything PHP can catch — so the failure path never ran, the stall watchdog restarted the job, and the cycle repeated without bound while the caller polled state: "running" with a frozen table counter and no error, indefinitely. Measured on a production site (Extractor 0.3.0, PHP 8.4, MariaDB 11.4), a 12 MB / 2,909-row wp_posts completed while a 56 MB / 119,674-row wp_relevanssi made no progress in five minutes and a 493 MB / 100,893-row wp_postmeta none in forty — which made the site unclonable, since wp_postmeta is content and cannot be dropped from a clone. Tables are now dumped in bounded slices resumed across ticks, so the per-tick working set is one slice rather than a whole table.
  • A build whose chunk cannot complete now reports failed with a usable reason instead of being retried forever (ADR-0013). A counter incremented before each chunk and cleared by every real advance is what survives a kill that leaves nothing else behind; once a chunk has been begun max_stall_attempts times (default 3, KNTNT_EXTRACTOR_MAX_STALL_ATTEMPTS or its filter) without ever finishing, the job fails and its poll's error.message names how many attempts died, which table and row — or file and byte — they died on, and the host's memory_limit and max_execution_time, so the cause is diagnosable on a site with no debug log. A job that is merely slow is untouched: every completed chunk resets the counter.
  • A page cache can no longer strand the API on a single failed authentication (ADR-0012). Every response under kntnt-extractor/v1 — payloads, refusals, and unmatched routes alike, whatever the authentication state — now carries Cache-Control: no-store, no-cache, must-revalidate, max-age=0 and Vary: Authorization, and fires litespeed_control_set_nocache because LiteSpeed's page cache decides through its own control API rather than the response headers. WordPress sends its own no-cache headers only for a request it considers authenticated, and an attempt that fails to resolve a user is not: wp_authenticate_application_password() short-circuits silently on an unknown login, so the resulting refusal went out looking like a cacheable anonymous error. On a LiteSpeed production site that refusal was cached against the URL and replayed to every later caller, including ones presenting entirely correct credentials — one mistyped username locked the endpoint out until the cache was purged, and only a unique query parameter on the URL made the identical request succeed. The guarantee is applied at rest_post_dispatch, the one seam every response in the namespace passes through, so no endpoint can be added outside it.

v0.3.0

Choose a tag to compare

@TBarregren TBarregren released this 23 Jul 15:01

Added

  • POST /extractions rejects a selection naming a credential-bearing restricted path — wp-config.php and its backup/editor-droppings siblings (wp-config-sample.php excepted), .env and its siblings anywhere in the tree, and root-level database dumps and key material (#21, ADR-0011). The rejection is a 422 kntnt_extractor_restricted_path naming every offending path, decided before the existence check and the capability gate, so a misconfigured client learns its selection is wrong rather than silently receiving — or missing — the site's secrets. GET /files is unchanged: a restricted path stays listed, unannotated. Bumped the REST API version to 3 for this caller-visible contract change.

Fixed

  • GET /extractions/{id} no longer returns a spurious 404 kntnt_extractor_no_such_job for a live, progressing job (#20). The per-job job.json was rewritten in place with a bare file_put_contents(), so it was momentarily zero-length or partial on every save; a poll that read it inside that window saw an unparseable file and reported the job as vanished — which the client poll discipline treats as terminal, aborting a healthy clone. The write burst from the time-budgeted tick (#18) made the window easy to hit. The state file is now published atomically, written to a sibling temp file and rename()d over job.json (the same discipline the sealed artifact already uses), so a concurrent reader sees either the whole previous record or the whole new one — never a torn one. As defence in depth, find() now re-reads a present-but-unparseable file a bounded few times before it concludes the job is absent, so a 404 means a confirmed on-disk absence, never a transient partial read. Tick, sweep, and consume behaviour is unchanged.

Full changelog: https://github.com/Kntnt/kntnt-extractor/blob/v0.3.0/CHANGELOG.md

v0.2.1

Choose a tag to compare

@TBarregren TBarregren released this 23 Jul 08:03

Two extraction-throughput fixes for hosts where the self-loopback continuation is slow or never completes — heavy extractions now advance at tick/poll cadence instead of crawling at the cron watchdog's, and polls no longer block on the loopback nudge.

Fixed

  • Heavy extractions no longer crawl at the cron watchdog's cadence on hosts where the self-loopback continuation never completes (#18): a tick is now time-budgeted, packaging as many bounded chunks as fit in a configurable wall-clock budget (tick_budget, default 15 s; zero preserves the previous one-chunk-per-tick behaviour) within a single PHP invocation, so one tick or one watchdog patrol can carry a multi-chunk job all the way to ready instead of one chunk per cron cycle. The continuation nudge now fires once per tick, after the per-job lock is released and only while work remains, and its delivery is hardened (ignore_user_abort, a bounded cURL connect phase) so a dead loopback can neither stall the nudging process nor kill the tick it spawned mid-chunk.
  • GET /extractions/{id} and POST /extractions no longer block on the best-effort loopback nudge (#19): the continuation that keeps a queued or stalled job's driver alive now runs after the response has been sent, not before it, so a poll that should cost milliseconds is never held for tens of seconds on a host where loopback HTTP is slow to fail. Once the response is out, the worker drives the job in-process where it can detach from the client (fastcgi_finish_request/litespeed_finish_request), and otherwise falls back to the same guarded, hard-bounded nudge — now paid after the body is echoed. The REST responses are byte-identical and the API version is unchanged.

Full changelog: https://github.com/Kntnt/kntnt-extractor/blob/v0.2.1/CHANGELOG.md

v0.2.0

Choose a tag to compare

@TBarregren TBarregren released this 22 Jul 17:13

The kntnt-wp-skills#24 cutover trio: the three companion-plugin changes that let clone/pull leave the Novamira execute-php channel, shipped as one coordinated REST API version bump to 2.

Added

  • Structure-only table extraction (#16): POST /extractions accepts a tables_structure_only sibling list alongside tables, dumping those tables' DROP/CREATE TABLE DDL into the sealed artifact without any rows, so an artifact can carry every selected table's structure while carrying only some tables' data. A table may appear in tables or tables_structure_only but not both (422); an unknown structure-only table is a 404 decided before the capability gate; structure-only tables count toward the poll's table progress totals and are recorded in the sealed index and audit log like any other table.
  • Authorized GET /kntnt-extractor/v1/environment endpoint (#15) returning read-only site and runtime facts about the host — no extraction is created — so a caller can inspect the environment behind the same capability gate that guards the operational endpoints.
  • Authorized GET /kntnt-extractor/v1/extractions endpoint (#17) listing the caller's own non-terminal jobs (queued / running / ready), each with the same id, state, and timestamps a create and poll report and progress on the jobs that have advanced. A caller never sees another user's job, a terminal job is the audit log's concern and is omitted, and the listing discloses no download_url.

Changed

  • Bumped the REST API version to 2 for the coordinated #15/#16/#17 trio: the tables_structure_only request field and its structure-only artifact segments (#16) and the two new read endpoints GET /environment (#15) and GET /extractions (#17) are one caller-visible contract change shipped under a single version bump rather than one bump each.

Full changelog: https://github.com/Kntnt/kntnt-extractor/blob/v0.2.0/CHANGELOG.md

v0.1.1

Choose a tag to compare

@TBarregren TBarregren released this 22 Jul 11:08

Changed

  • Lowered the minimum PHP requirement from 8.5 to 8.4. No code depended on a PHP 8.5-only feature, so the plugin now installs and runs on PHP 8.4 hosts as well.

Full changelog: https://github.com/Kntnt/kntnt-extractor/blob/v0.1.1/CHANGELOG.md