Skip to content

v0.9.0

Latest

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 22 Sep 16:50
· 1 commit to main since this release

Added

  • Outbox::refused() keeps a write the server processed and refused as abandoned under the server's status, so a refused create goes on holding back its children and the refusal outlives the push that received it. dismiss() takes a dismissed create's dependants along as parent_abandoned, and requeue() refuses a receipt_pruned or protocol_violation write - one that may already be on the server - unless told evenIfItMayHaveLanded.

  • Engine::submit() answers like process() and says whether this call wrote the mutation, so a host that writes its own table for a landed write does not repeat that work for a replay that raced it.

  • Views\CurrentStateView, for a rule that judges a record as it is now - a host permission check on its own row. A change such a rule does not show now is delivered as a removal, the id and nothing else, whenever the underlying window spans either side of it. Judging history by the current row sent the content of a row created and deleted since the cursor, and never told a previous owner that a row had left.

  • A writer can decide its own conflicts. Engine::process() takes an optional OnConflict. With OnConflict::Pull, a field the resolver would have preserved is refused instead: status pull_required, and nothing is stored - no record change, no conflict group, no receipt, no acknowledgement, no commit. MutationResult::$conflicts names the contested fields and $recordVersion the version that carries them, and the writer sends the same mutation again, rebased on that version. Decisions the resolver makes itself - client wins, server wins, reject - are untouched, so a writer cannot use this to get around them. Mutation::rebased(), Outbox::rebase().

  • The outbox gives a device what it was missing: requeue() and dismiss() for an abandoned write, nameOf() for what a created record was called, and a rewrite of the fields the application names as references when a created record is named, so a child created offline reaches the server pointing at its parent's real id.

  • Contracts\OutboxStore gains replace(), resetAcknowledged(), nameOf(), dismiss() and queued(), and head() takes a space.

  • Engine::recordTrusted() for a host's own writes - a model save - deciding create or update, the base version and the stream position inside the space lock. Deciding them before the lock made concurrent saves race for a position.

Upgrading

  • Run migrate() - or PdoSchema::forConnection($pdo)->install($pdo) from your own migration - once after upgrading. It adds the new receipt and stream columns and indexes, gives receipts from earlier releases their stream position, and on MySQL retypes identity columns to utf8mb4_0900_bin, which copies each table: run it in a maintenance window on a large installation. Until it has run, writes fail.
  • A device's PdoOutboxStore::migrate() adds its new columns in place on first use - restart every process that uses the outbox after it has run, since one still running the old code writes rows the new one does not see; writes queued before the upgrade count as sent once. Back the file up first; downgrading is not supported.
  • Outbox::queue() refuses a create whose handle the device already uses for a record in another space. An application that gave records in different tenants the same local id has to give each its own - a UUID.
  • Outbox::abandon() runs in its own outbox transaction now; do not call it inside one of yours on the same connection.
  • A device keeps sending writes queued before the upgrade on the stream they were queued on, so their retries and depends_on still match. OutboxStore implementations outside this package need the new methods, and the PDO outbox gains an entity_id column in place.
  • Format-2 payloads cannot be read by 0.8.x; a downgrade after writing is refused by name.

Fixed

  • A duplicate delivery inside a host's transaction is answered from its receipt. On MySQL at REPEATABLE READ the host's snapshot predates the space lock, so a retry whose first copy had just committed was answered sequence_behind; the device renumbered a write that had landed and then abandoned it. A position already used is now looked up again with a read that sees the latest commit, and so is a dependency.

  • An echo is the writer's own knowledge. recordTrusted(..., echoOf:) records what the host's table made of a device's write and folds its versions into that write's answer, so the device's next edit does not conflict with its own write. asCreate: writes every field when the log has never held the record - a row that existed before it was synced. An If-Match on a record the log does not hold fails the precondition instead of creating it.

  • A restored device is not stuck behind the pruned range. settledUnknown() takes the server's acknowledged sequence; when the server is further along than the device, every write still queued on that stream is settled together and new writes go on after the server. It used to burn one position per write, so the device could write nothing new until it had crawled through the whole range.

  • A failed rollback no longer hides the failure that caused it (a deadlock ends the transaction on MySQL, and the rollback after it failed too); SQLite's busy and PostgreSQL's lock timeout count as contention; a space name is checked before its row is created, and a missing space row fails instead of locking nothing.

  • Handing out a write and rewriting it for a parent's new name lock the row on MySQL and PostgreSQL device stores, so neither overwrites the other with the copy it read before.

  • Installing the schema gives receipts from earlier releases their stream position (in PdoSchema::install(), so hosts whose migrations call it get it too, and safe inside a migration's transaction), and a position that already has an answer - a stream and its receipts that disagree after a partial restore - is refused as that, not as a retryable duplicate.

  • Ledger gains amendReceipt() and receiptAt(), which reads a stream position's answer as the latest committed state by an exact lookup on a new unique (space, replica_id, sequence) index - so inside a host transaction it locks that one row. A replayed duplicate and a dependency on the device's last few writes are looked up this way when the host's snapshot predates them.

  • An echo moves the writer's answer to its own version only when nothing came between the write and its echo, and adds only the fields it actually wrote - otherwise the device's next edit, based on that answer, overwrote another writer's change unseen.

  • The outbox knows which writes may already be on the server: one refused as receipt_pruned or protocol_violation, and any write with a sending that got no answer at all (OutboxStore::countSend(), countAnswer(), clearSends(), unanswered(); Outbox::answered() for an answer that leaves the write queued). A sending answered in a way that proves it did not land (Outbox::answered(), for "sign in again") is accounted for, and a gap, a pull_required or a processed refusal proves none did; a "busy" answer proves nothing, since a gateway's timeout can look the same. abandon() takes answered: false for a write given up on without an answer, and now runs in its own outbox transaction. A write queued before this release counts as sent once, since nothing recorded whether it had been. requeue() refuses a may-have-landed write without evenIfItMayHaveLanded; dismiss() of such a create abandons the writes that need it as parent_unknown rather than parent_abandoned - this device never learned the record's name either way. dismiss() looks up the one row (OutboxStore::abandonedOne()) and returns how many dependants it took along. A dismissed create is kept, unreported (a dismissed flag on the outbox row), because it is what still says its handle was never named - until a new create for that record is queued or accepted: a write that needs a record whose create was abandoned or dismissed and never named is requeued only once that create is requeued or the record is named with Outbox::found($handle, $name) - every such record, not just one. found() accepts only a create this device abandoned, not queued again and not named, so it cannot move another tenant's writes. orphanReason() gives a push the right reason for a child (parent_unknown when its parent may have landed), dismissing such a parent relabels children already abandoned, and mayHaveLanded() answers for any abandoned write. OutboxStore gains setReason(), abandonedCreate() (with an optional space), forget(), markDismissed(), forgetDismissedCreates(), createFor() (a record's create wherever it sits in the queue, by an index on a new kind column) and recordName(); rekey() takes $creates. A record accepted under its own id is recorded under it, so nothing waits on an earlier refused create for it; checks about a record are made in its own space. A handle names one record on a device: Outbox::queue() refuses a create whose handle the device already uses for a record in another space (OutboxStore::handleSpaces()), because a reference carries no space and could be pointed at either; nor may a handle equal a server id - use UUIDs. A scope's rename moves abandoned and dismissed writes along with the queued ones, so a handle never ends up in two spaces that way. createFor(), abandonedCreate(), inFlight(), rekey() and the name lookups are served by indexes on every driver, and an abandoned write leaves the in-flight range, so draining stays linear; a scope's rename moves the names recorded under it too. Outbox::relatedBy() holds the application's references for calls not given them.

  • A restored stream is settled across every type on it (OutboxStore::queuedOn()), and only by the answer that is still current - a late copy of it no longer settles writes queued since; settledUnknown() returns how many it settled. mapNames() re-checks after locking that no other process has sent the write, stream counters are created without locking an existing row (two hand-outs deadlocked on MySQL), and device-store contention is a TransientFailure.

  • A handle is only unique within its space. A queued write's own record is renamed by the name given in its space; a reference or a scope is not rewritten when two spaces named the same handle differently, nor when a create for that handle is still queued - a handle reused for a new record used to be pointed at the old one. Tenant B's update of its local-1 used to be rewritten to tenant A's record.

  • A late copy of a gap answer no longer takes a newer attempt's number away, which later made a write that had landed be refused as a reused identity. OutboxStore::resetAcknowledged() returns whether it applied.

  • A write an earlier release left in flight is numbered the way that release would have, not resent under the placeholder number its payload carries (numbered column, set on migrate).

  • The MySQL device store runs its transactions at READ COMMITTED and creates stream counters with one statement, so a process waiting behind another's hand-out sees what that one sent; a write moved to another scope while being handed out is looked up again rather than numbered on the old scope's counter.

  • The first writes to a new space no longer race: the space row is created with a statement that cannot fail on a duplicate, which on PostgreSQL used to abort the host's surrounding transaction.

  • A device's numbering could disagree with the server's. Each entity type and space a device writes to is now its own replica stream. The server numbers per replica per space and maps types and scopes to spaces by rules the device cannot see; one stream per (type, space) keeps both sides counting the same thing, where a single lost response used to make a write collide and be abandoned for good.

  • A device out of step with the server after a restore could never push again. A server restored from a backup answered every write with the same gap; resumeAfter() now sets the acknowledgement exactly, downward too, and only if the counter is still where that attempt numbered from. A device restored from a backup reused numbers the server held and had every write refused as a protocol violation; a new identity on a used number is now answered as a gap with reason sequence_behind, and the writer renumbers upward - except at or below the highest position of that stream whose receipt was actually pruned, where it could be a replay whose answer is gone. That is answered receipt_pruned: final for that one write, never renumbered and applied twice, but carrying where the stream is so the writer goes on with the next. Ordinary acknowledgements still only rise.

  • A dependency pruned with the log, or on another of the writer's streams, refused the dependent write. It is treated as no knowledge now: the write is judged on its own base.

  • Acknowledging a create and renaming what is queued behind it are one transaction. A crash between them used to leave updates addressed to a handle nothing could resolve.

  • MySQL treated a and a as the same identifier. utf8mb4_bin pads with spaces, and a mutation id differing only by a trailing space was answered with another mutation's receipt. Identity columns use utf8mb4_0900_bin, and migrate() retypes older installations. MySQL 8.0.17 or later is required.

  • One oversized identifier could stop every bootstrap of its view. Identifiers are capped at 150 characters - what the columns hold - and may not contain NUL, in EntityKey, Replica and Mutation, so every entry point inherits it.

  • A preserved candidate skipped the value checks. A conflict leaves the record unchanged, so the validator never saw the value being kept. The record is now also validated as it would be if the candidate were chosen.

  • Receipts grew without bound. prune() drops the receipts written in the commits it removes. A replay older than the horizon is answered receipt_pruned and applies nothing.

  • Identifiers must be valid UTF-8 - MySQL and PostgreSQL refused anything else with a driver error that SQLite stored.

  • A write handed out for sending is never rewritten by a later rename, since it may already be on the server. Writes scoped by a record created offline move to its name (scopedBy), and requeue() maps an abandoned write through every name given since. resetAcknowledged() is one atomic compare-and-set.

  • On MySQL, concurrent writers mostly failed. In one space, a writer inside a host transaction numbered its commit from a snapshot older than the space lock and hit the commits primary key; across spaces, REPEATABLE READ's gap locks on shared indexes made writers deadlock each other (807 deadlocks for 600 writes in six spaces). The store's own transactions run at READ COMMITTED on MySQL now; inside a host's transaction the ledger uses locking reads. A deadlock or lock-wait timeout is a TransientFailure on every driver. bin/concurrency.php retries only that, counts it, and has a --spaces=separate mode that CI runs.

  • Two device processes could hand out a stale copy of a write - undoing another process's rename or reference rewrite - or give two writes one number on a MySQL or PostgreSQL device store. Handing out re-reads the write inside the transaction and holds the stream's counter.

  • A truncated deflate payload is refused instead of decoding to a prefix, and a write that would store a row larger than the reader accepts fails instead of producing a commit nobody can pull.

  • An atomic proposal that preserves a conflict is validated whole, as it would be chosen.

  • The PDO outbox could not be installed on MySQL at all (CREATE INDEX IF NOT EXISTS), and client schemas created by earlier releases kept utf8mb4_bin. Both are fixed, and the outbox suite now runs on MySQL and PostgreSQL too.

  • Identifiers are bounded where a write is made rather than wherever a key is built, so a record an earlier release stored with a longer id stays readable.

  • A change could be recorded as a Record carrying no record; found by the strict analysis rules.

Performance

  • The log stores about a tenth of what it did. Payloads are deflated (format 2): a one-field edit on a ten-field record went from ~22KB of commit to ~2.6KB, for ~20µs more per write. Inflating is bounded at 16MB. Formats 0 and 1 are still read. Requires ext-zlib.
  • Delta narrowing reads two index ranges instead of an OR the SQLite planner could not index.