Skip to content

v0.153.1

Choose a tag to compare

@github-actions github-actions released this 15 Sep 15:06
· 647 commits to main since this release

Fix — backup full FROM a PlanetScale Neki no longer dies at the finalize phase. In every release that recognises a Neki source (v0.150.0 through v0.153.0) the copy completed across every shard and the backup then exited non-zero on the end-position read, leaving a complete, readable store on disk. Loud, never silent; nothing to re-verify. Drop-in: no flags, no config, no state or on-disk format changes. The one visible difference is that a full backup from a Neki now finishes and records an EMPTY EndPosition with a WARN saying so. Everything else in this tag is the weekly live-Neki suite and the TESTFRAGILE-3 root cause — measurement and test infrastructure, not shipped behaviour — with one narrow product change in how an externally cancelled overlapped migrate names its phase.

Fixed

backup full FROM a sharded PlanetScale Neki completes. Measured 2026-09-15 on a live 2-shard cluster by the suite's new backup arm: the IR copy path read every row across both shards (the raw-COPY lane is declined on a Neki), the snapshot opener fell back with the expected replication connections must target a specific shard (0A000) WARN, and then the end-position capture called pg_current_wal_lsn(), which the router does not implement — not implemented: opcode not implemented: pg_current_wal_lsn (SQLSTATE NK013) — so the backup failed at the finalize phase with all of its chunks already written. A Neki has no WAL position to record and nothing that could consume one: a router-level replication connection is refused, which is why a Neki cannot be a continuous-sync source (ADR-0186 probe R-1). The fix takes the shape the orchestrator already had for engines without a position capturer. internal/ir/backup/preflight.go gains irbackup.ErrPositionUnavailable; the postgres capturer in backup_position.go returns it when the reader's flavor probe says Neki — decided from the probe, not by catching the router's error text, so it cannot drift with the message; and captureEndPosition in internal/pipeline/backup/backup.go treats it exactly as "no capturer": the manifest's EndPosition stays empty and a WARN names the consequence — no backup incremental can chain off this full on this source. Every OTHER capture failure still refuses, because a lost position on a source that has one is the silent half. Pinned in both directions: TestRecordEndPosition_UnavailablePositionLeavesEndPositionEmpty, TestRecordEndPosition_OtherCaptureErrorsStayLoud, and TestCaptureBackupPosition_NekiAnswersUnavailableWithoutQuerying, which proves on a dead-port pool that the Neki branch answers before any query and that a non-Neki reader does not answer the sentinel; all mutation-run. Sibling sweep of the engine's other pg_current_wal_lsn() readers on a Neki source: diagnose already degrades in place; ReadCurrentWALPosition, the health reporter and the slot-health reporter are reached only from a sync whose source is a Neki, which dies at the replication connect on the same road — loud either way, filed for a better sentence rather than changed here. The live arm's second run (backup GREEN end to end, read back from the artifact's own chunk files) is the independent evidence; the per-PR forced-flavor plumbing test backs up from a vanilla server whose snapshot opener succeeds and never reaches this path, which is stated rather than implied.

backup incremental and backup stream refuse to extend a full that recorded no end position (POSITIONLESS-FULL-ROOT). Found by the pre-tag value-fidelity review of this release — once again in the release's own headline fix. The fix above records an empty EndPosition and WARNed that nothing could chain off it — but the only thing behind that promise was the router refusing replication connections, and this release's own shard-targeting probe measured that a shard-targeted replication connection is ACCEPTED. Meanwhile resumeStartFromParent in internal/pipeline/resume_start.go, the chokepoint both chain extenders share, still carried the v0.16.x compatibility branch: a FULL with no position started the chain from the source's CURRENT position after a WARN, at exit 0 — a chain permanently missing every change between the full's read and the incremental's open, and on a shard-targeted Neki DSN, one shard's changes only. The population that branch admitted was never just v0.16.x fulls: a MySQL full taken with the binlog off records the same empty position, the pre-existing sibling the same door now closes. A positionless full on any source whose reader resumes from a recorded position now refuses with ir.ErrPositionInvalid under the POSITIONLESS-FULL-ROOT marker, naming the producers and the remedy (a fresh backup full on a source that records one), before any CDC reader is opened and before anything is written to the store. The one exemption, stated at the door: trigger-CDC sources (pgtrigger, sqlite-trigger, d1-trigger), whose fulls carry no position by construction and whose readers anchor "from now" at the change log's current id. That is the same gap class, pre-existing, and it is filed as roadmap item 163 rather than closed here by refusing every trigger-engine chain. Pinned at the chokepoint in both directions and through both extenders — TestResumeStartFromParent_PositionlessFullRoot, TestIncremental_RefusesPositionlessFullRoot, TestBackupStream_RefusesPositionlessFullRoot, TestIncremental_TriggerCDCSourceStillAnchorsFromNow — and mutation-run both ways, each failure read to confirm it came from the new assertion and not from the fake reader's own refusal of an empty position. The live backup arm now asserts on the real router that the manifest finalized and its EndPosition is empty, instead of logging them.

The index-build tuning probe no longer WARNs pg_size_bytes … NK013 on every restore or migrate into a Neki. resolveIndexBuildConcurrency in schema_writer.go probed the tuning GUCs on a throwaway query, and on a Neki the probe failed with NK013 on every run, WARNed, and fell back to the serial plan — which is the right plan there anyway, because every Neki index build goes through the online-DDL workflow (ADR-0184) rather than a session sluice tunes. The serial plan is now taken directly on a Neki. Byte-identical to the measured behaviour, minus the noise.

An externally cancelled overlapped migrate no longer blames an index axis that had nothing to do. This is the one product-code change from the TESTFRAGILE-3 root cause, in migrate_index_overlap.go. On Postgres the copy axis and the index axis run in one errgroup, and Wait returns whichever goroutine returned first. That is a correct attribution when the failure originates in an axis, but when the cancellation comes from outside the group — a deadline, a Ctrl-C, a stall watchdog — both axes see it at once, and with zero index jobs (a single-integer-PK fixture has none: the PK is constraint-backed and skipped) the index axis's drain loop returns a bare ctx.Err() on its next select while the copy axis is still unwinding real database calls. So every externally cancelled overlapped migrate was labelled create indexes regardless of where it was blocked, and that label sent two investigations at a phase with nothing in it. attributeOverlappedFailure is a pure function over the group error, the copy axis's own error and indexJobsQueued — what the pipeline HANDED the index axis, not what the engine built. Deliberately narrow: the only case it reclassifies is zero jobs queued AND an index error that is nothing but context.Canceled / DeadlineExceeded; an axis that was handed work still names the failure, and every non-context index error is untouched, so the errno-3024 hint path this attribution was built for is unaffected. Resume is unaffected too — every resume decision reads per-table progress, never the persisted phase. Gated by TestAttributeOverlappedFailure (five cases including both unchanged directions) and TestOverlapPhase_IdleIndexAxisDoesNotNameTheFailure, which drives the real orchestrator; mutation-run.

Test infrastructure, not shipped behaviour — the TESTFRAGILE-3 root cause. TestMigrate_FastLoader_CrashMidFastChunk_ResumeIsIdempotent/postgres wedged at ~114 s on roughly half of tag CI runs, and the v0.153.0 reading of it as contention is refuted on the data: five CI durations of 2.07, 1.76, 114.13, 1.79 and 113.82 s are a wedge, not a slow runner. Four independent defects sat behind that one line. (1) A connection leak: trackingWriter wrapped the Postgres RowWriter and forwarded every write method but had no Close, and the pipeline releases writers through an io.Closer type assertion, so every close was a silent no-op and the engine pool was never released — measured at 9 leaked backends per run, and nine crash-resume iterations against one container reach too many clients already. Fixed on all four wrappers, with two exempt by reason, and held by TestRowWriterTestWrapperRoster_EveryWrapperClosesItsInner, an AST roster of every test-local ir.RowWriter wrapper in internal/pipeline with a fail-by-default exemption map and a floor of four, plus a server-side pg_stat_activity count in the fast-loader leg for the half the AST cannot see. (2) captureSlog in migrate_test.go restored the default slog logger but not the std log package's output — slog.SetDefault skips that call when reinstalling the internal default handler — so from the first caller onward every sluice log line in the binary went to a finished test's buffer; the v0.153.0 job shows ~17 minutes of silence across ~700 tests, which is why the wedged run had nothing to read. Fixed and pinned by TestCaptureSlogRestoresTheStdLogSink; the same shape exists in 88 other test files but each test binary is its own process, so those silence only their own package — deferred as a separate chunk, stated rather than left implied. (3) The phase attribution above. (4) progressWatchdog now dumps every goroutine's stack and, on a Postgres target, pg_stat_activity and ungranted pg_locks BEFORE it cancels, under the grep handle PROGRESS-WATCHDOG-STALL, entirely best-effort; dumpStall has its own gate. The wedge itself is not claimed closed: if the leak was it, the leg stops wedging; if not, the next occurrence carries its own evidence instead of costing a rerun.

The weekly live-Neki suite: restore INTO and backup FROM a sharded cluster, measured. Two arms in nekiverify_backup_restore_test.go, with their router-free half proven per-PR against vanilla PostgreSQL under the forced flavor (TestPostgresSuite_NekiBackupRestorePlumbing, a shared core under integration || nekiverify), which caught two harness faults before a cluster was ever provisioned. nekiRestoreIntoShardedTarget: a SQLite-built full backup (20,000 + 500 rows, six chunk files) restored through the real restore door — schema-apply, a six-chunk copy, two secondary indexes through the Neki online-DDL workflow (21 s and 16 s), the large table scattered 10,000 / 10,000 across both shards, content byte-exact against the Go-side expected set; green twice. nekiBackupFromShardedSource: the scattering read on the IR copy path, row count graded against an independent Go-side count, backup verify --depth read reported but not relied on for content, and the artifact read back as a direct stream of its own chunk files rather than by restoring into whatever earlier arms left behind; green after the fix. Stated as NOT COVERED: a single-full restore creates no control tables (only a chain restore reaches the applier door), so the restore half of ADR-0187's placement is a chain-restore arm this suite does not yet build. Restore resolved table-parallelism 1 against the router (target connection budget allows only one writer) — correct on the COPY ceiling, filed as a perf item, not changed. Every arm now cleans its own schema residue, with the one exemption (the MoveTables source table, enrolled in the topology) documented in place.

What a shard-targeted session can do, measured seven ways. nekiShardTargetedSessionProbe (nekiverify_shard_targeting_probe_test.go) rides the 2-shard fixture and answered every measurement in 4.9 s, each pin on a private pool so a targeted session can never be handed to a later arm, and each preceded by a tenant-placement check so a SET __neki.shard that silently did nothing would fail the arm rather than answer for the router. Under targeting: pg_current_wal_lsn() answers per shard, and the two positions differ; pg_export_snapshot() works; a targeted REPEATABLE READ is a real per-shard snapshot (the count held across a concurrent router-side INSERT that demonstrably landed on that shard); a per-shard union of a sharded table is complete and non-duplicating (per-shard sum equals the router's count, each row visible on exactly one shard); COPY FROM STDIN is refused with 0A000, as the vendor documents; a targeted INSERT of a row whose key routes ELSEWHERE lands on the targeted shard and is invisible to the router's equality-routed read while visible to a scattering one — the silent shape, and the evidence for the rule that sluice never writes under targeting; and a shard-targeted replication connection is ACCEPTED, in both the options= and bare-startup-parameter spellings, with IDENTIFY_SYSTEM answering. Read together: a Neki can be a per-shard consistent-read source and a per-shard CDC source BY MECHANISM. That is design work against the pipeline's single-position model (the composite-position shape sluice already has for Vitess), now demand-gated on evidence rather than on a missing mechanism — nothing of it ships here, and a Neki is still not a CDC source today.

Per-arm budgets, a census on stalls, and the copy-limit measurement retracted. Every arm of the sharded suite now runs under nekiArm with a ceiling of its own, sized against measured cost, so a stall is attributed to and costs the arm that stalls; a run that previously exhausted its shared 30-minute context before eight arms ran now finishes in ~300 s. nekiReadBackends samples __neki.stat_get_activity() (falling back to pg_stat_activity) and nekiWatchStoreProgress fires it when a backup's store stops growing for 60 s, because one run's backup read stalled for 445 s on a read that took 2.0 s an hour earlier — recorded as a hypothesis about the pinned-session read path, not a finding, for the census to settle. The copy-limit arm's earlier reading that the platform "admits at least 12" concurrent COPYs is RETRACTED: three runs logged 12 accepted and then, in the same second on release, too many concurrent COPY operations (limit: 4) on sessions 5–12 — acceptance had been inferred from four seconds of silence, and the router defers its refusal until the client sends more. Acceptance is now proven by a sustained ~512 KiB burst per session (sustainedCopyReader, exercised per-PR by TestPostgresSuite_NekiCopyBurstApparatus in neki_copy_burst_test.go) with nekiCountRouterCopySessions asking the router for its own count as the independent cross-check; an accepted session that comes back 53300 on release makes the arm INCONCLUSIVE rather than a number. The first run under that criterion accepted five sessions and then had session 6 closed by the router mid-burst — the deferred refusal arriving as a connection close — so a router-closed session is now counted as refused; the run after it (34940466964) showed one of the five accepted sessions coming back 53300 on release — refused late — so the admitted count is the sessions that never met a refusal in any form, with the release outcome as the per-session evidence the burst alone cannot be. Measured: 4 admitted, exactly the platform's own limit: 4 and exactly nekiConcurrentCopyLimit. The premise holds; the "at least 12" and "at least 6" figures filed earlier were artifacts. Admitting MORE than sluice's pace is now logged under NEKI-COPYLIMIT rather than failed, so the weekly is no longer permanently red on a condition nobody was going to act on; fewer, and inconclusive, still fail.

scripts/vet-tags.sh and vet-tags.ps1 accept a top-level a || b build constraint, expanding it into one combination per disjunct; grouping and negation are still refused loudly.

Compatibility

No flags, no config, no control-table, resume, backup or restore format changes. A full backup taken FROM a PlanetScale Neki now succeeds and carries an EMPTY EndPosition, with a WARN at capture time; backup incremental and backup stream REFUSE to chain off it (POSITIONLESS-FULL-ROOT) — a Neki cannot be a CDC source today (ADR-0186), and the refusal is what makes that a fact rather than a promise. One behaviour change: extending a chain off ANY full that recorded no end position — a full older than v0.17.2, or a MySQL full taken with the binlog off — used to proceed after a WARN and start the chain from the source's current position; it now refuses. If you relied on that, the chain it produced is missing every change between the full and its first incremental: take a fresh full on a source that records a position. Trigger-CDC sources are exempt (their fulls never record one) and keep the WARN. Every other engine, and every non-Neki Postgres source, captures its end position exactly as before, and every other capture failure is still a refusal. The overlapped-migrate attribution change reaches only Postgres targets on the overlapped copy/index path, and only an externally cancelled run whose index axis was handed no jobs; the error text and persisted phase in that one case change from create indexes to the copy axis's own error, and no resume decision reads the persisted phase.

Who needs this

  • Anyone running backup full from a PlanetScale Neki. In v0.150.0 through v0.153.0 that command copied every row and then exited non-zero at the finalize phase on pg_current_wal_lsn; the store on disk was complete and readable but the manifest was never finalized. Upgrade and re-run; the run now completes with a WARN that the manifest has no end position. Nothing to re-verify — the failure was loud. backup incremental from that full refuses (POSITIONLESS-FULL-ROOT); there is no position for it to chain off.
  • Anyone who extended a chain off a full with no recorded end position — a MySQL source whose binlog was off at the full, or a full older than v0.17.2. That chain started "from now" and is missing the window between the full and its first incremental; the extension now refuses instead. Take a fresh full on a source that records a position and start a new chain.
  • Anyone restoring INTO a sharded Neki. Nothing changes in behaviour; it is now measured green end to end on a live cluster, and the pg_size_bytes … NK013 WARN on every run is gone. A restore into a Neki is single-writer across tables today (filed as a perf item).
  • Anyone diagnosing a cancelled overlapped migrate on Postgres that reported create indexes: context canceled with no indexes to build: that label was the first-to-return artifact described above, and the copy axis's own error is now what you see.
  • Everyone else. Nothing to do; nothing changes.