Zero 1.12
Synced Aggregates and JSON Path Filters
Installation
npm install @rocicorp/zero@1.12Overview
Zero 1.12 syncs aggregates like count() and sum(), groups rows with groupBy() and distinct(), and can filter on values inside JSON columns. zero-cache hydrates queries and applies updates up to 1.8x faster in our benchmarks, React Native apps start about 3x faster, and each replication-manager now gets its own replication slot. This release also fixes eleven security vulnerabilities, along with a lot of other fixes, especially in replication and connection handling.
Security
Zero 1.12 includes the fixes for the eleven security vulnerabilities fixed in 1.11.5. See the 1.11.5 release notes for the advisories. We recommend that everyone upgrade. The fix for client-claimed user IDs is off until you turn it on. See Server-Validated User IDs.
Some of these fixes change how Zero behaves, so read Breaking Changes before you upgrade, especially the entries on userID and the change-streamer secret.
Upgrading
Server-Validated User IDs
zero-cache can now refuse a userID that your API didn't confirm. To use it:
- Return the validated
userIDfrom your/queryand/mutateendpoints, ornullfor logged-out callers.handleQueryRequestandhandleMutateRequestdo this when you pass themuserID, as recommended since 1.5. - Set
ZERO_AUTH_REQUIRE_SERVER_USER_ID=true.
Until you do, zero-cache trusts the userID the client claims and logs a warning. Apps that verify tokens with ZERO_AUTH_SECRET, ZERO_AUTH_JWK or ZERO_AUTH_JWKS_URL don't need this: the token already verifies the userID. (83efa9462, c1f36d79e)
RMv2: Replication-Manager Slots
We're reworking how replication-managers replicate from Postgres, so that several can run side by side and one can take over from another without a pause. We call this RMv2. Zero 1.12 ships the first step and turns it on by default: each replication-manager now gets its own replication slot.
A new replication-manager restores its replica from the backup, catches up on its own slot while the old one keeps serving, and then takes over. Slots of replication-managers that are gone are dropped automatically. (49607b8b3)
A new replication-manager also starts about 20 seconds faster, once both the old and the new one are on 1.12. The new zero.replication.server_start metric shows where the start time goes. (522d2ce57, 9aca1b0ab)
This needs Litestream v5 backups, the default since 1.11. Expect one slot per running replication-manager in Postgres, each holding WAL until it is consumed. To go back to a shared slot, set ZERO_UPSTREAM_PG_REPLICATION_SLOT_PER_REPLICA=false. That doesn't need a resync.
Running several replication-managers at once will come in a later release.
Features
- Queries can return a single aggregate:
zql.issue.where('open', true).count(), orzql.issue.max('numVotes'). A relationship can be reduced the same way, sorelated('comments', q => q.count())gives each issue its number of comments instead of the comments. Only the result is synced, and it stays up to date as rows change. Counts, sums and averages of direct relationships update optimistically when a mutator changes the related rows.sum(),avg(),min()andmax()can also reduce a value inside a JSON column. (c9d1b15fc, e2ecf69ce, 642aece86, 523d3b97e, 8b716b3fe) distinct()returns the distinct combinations of some columns, andgroupBy()computes an aggregate per group:zql.issue.groupBy('assigneeID').count(). Groups appear and disappear optimistically forcount(),sum()andavg(). (effd2da27, 5493ba549, fe168e640)where()can compare a value inside ajson()column:cmp(json('metadata', 'priority'), 'high'). Paths can go through object keys and array indices, comparisons are type-strict, and TypeScript checks the path of a typed JSON column. JSON path filters run on the client, in SQLite and in Postgres. (0a1a0f3d4)- Draining behind a load balancer: a new
ZERO_PRE_DRAIN_DELAY_MSmakeszero-cachefail its readiness check for a while afterSIGTERMwhile still serving, so the load balancer stops routing to it before the drain starts. With sticky sessions, setZERO_AFFINITY_COOKIE_NAMEandzero-cacheexpires the cookie on connections it turns away while draining. A newzero.sync.drain-timemetric estimates how long a view-syncer's drain will take. (f22ec09b6, f77997349, 9a74d2d3c, 04a27634b) - Clients retry a failed connection sooner: after 250–500 ms the first time, backing off to the usual 5 seconds by the fifth failure, with jitter so clients that lost the same server don't retry together. A draining
zero-cachealso tells new connections to retry within a second instead of after 5 seconds. (06622e6ec, a5a940321) - Replication status events are republished when the replica grows by 1 GiB or 5%, so consumers that size storage from
replicaSizesee growth from ordinary writes. (76a0a1bd1) - Multi-node deployments can authenticate view-syncers to the replication-manager with a shared
ZERO_CHANGE_STREAMER_SECRET. A single-nodezero-cachegenerates its own. (b2b8f68e4) ZERO_ENABLE_HEAPZ=falseturns off the/heapzheap-snapshot endpoint while keeping the other diagnostic endpoints and the inspector. (ea3105333)
Performance
Server-Side Hydration
When a client connects or adds a query, zero-cache has to hydrate it: run it against the replica and send the results. In Zero 1.12 this takes about 1.2x less CPU, and about 1.8x less for queries with whereExists, which are common because permissions use them. We measured 100 client groups on one sync worker, each hydrating its user's top issues with their latest comments. (6563d5751, 70c771950, c2882cba0, 5a5fb1af1, c9c34d145, f7233246c, ae794cc0e)
Server-Side Hydration
Measured on M5 Pro. Lower is better.
Server-Side Incremental Updates
When a transaction replicates, zero-cache has to bring every client group's queries up to date. Zero 1.12 does this with about 1.4x to 1.7x less CPU than 1.11, which was already 2.5x better than 1.10. We measured the same workload as last release: 100 client groups on one sync worker, each syncing a query with a related subquery. Pushing one write through 300 open queries is about 1.6x faster too. (6563d5751, a35b628ea, 018a90f86, f5d11d924, 8702aea45, fd30e6437)
Server-Side Incremental Updates
Measured on M5 Pro. Lower is better.
React Native Cold Start
When a React Native app starts, Zero loads the rows it has stored on the device into memory and runs the app's queries over them before anything renders. In Zero 1.12 this is about 3x faster. Hermes, the JavaScript engine React Native uses, was spending most of that time sorting rows that were already in order. We measured an app with 180,000 stored rows and 21 queries, on the Android emulator and the iOS simulator. (bfc7e8c24, e7f8a1d91, 8c21e7960, ea4560f4e, e058bfca2, 6375eeb84, 855c495b1)
React Native Cold Start
Release builds, measured on M5 Pro. Lower is better.
Query Planning
Before zero-cache hydrates a query with whereExists, it plans which way to run each join. With three to five whereExists, or nested ones, planning now takes about 2x to 10x less time. (9215b24b2)
Query Planning
Measured on M5 Pro. Lower is better.
Fixes
Queries and Mutations
- Server mutators and queries read
time with time zonecolumns as negative milliseconds when the zone offset was positive, and aServerSchemawritten by hand with the shorttimestamptzname read a timestamp as a time of day. (9a08f1e40) - Server mutators and queries failed with
type "..." does not existfor enum and other user-defined Postgres types outside thepublicschema. Casts are now schema-qualified. (8166580b0) (thanks @jschaf!) - A push that failed at the API server could put other tabs of the same client group into the
Errorstate, or fail a socket the client had already replaced. A failed push now affects only the socket that sent it, and that socket's queued pushes are re-sent on the next connection. (945ce6192) (thanks @pawarren!) runAnalyzeCLI --queryno longer evaluates the query text as JavaScript. It accepts query builder calls, literals and arrow functions, and rejects anything else with its position. (02b6c1bbd)
Clients and React Native
- On React Native, client garbage collection failed every 5 minutes, and queries whose
related()entries weren't in alias order threw during normalization, because Hermes lacksArray.prototype.toSorted. (b62ded385) (thanks @wikil!) - A SQLite-backed store whose
close()threw blocked every laterZeroinstance with the same name from opening, and two overlappingclose()calls could close a shared SQLite connection under another store. (dee69dc9d, cc861e8f6) (thanks @wikil!) - An app that refreshed its auth token while the first connection of a new client group was still being validated could be refused for good with
The initConnection message for a new client group must include client schema. (874e1ec9d) (thanks @YevheniiKotyrlo!) - After
zero-cachewas rolled back, a client group holding a query the older server could not parse was stuck in an error state until the server rolled forward. The group is now reset and the client starts a fresh one. (dbf677638) - A write that put keys from both the upper Basic Multilingual Plane (U+E000 to U+FFFF) and above it (such as emoji) into the same B-tree node failed with
putMany entries must be sorted and unique, and a diff could report such a key as removed and re-added instead of changed. (0e15506e8) (thanks @natew!) - Client garbage collection left a store open for every database it inspected. With SQLite, closing
Zerothen no longer closed its database connection, and a customStoreProviderwhosedroprefuses an open store failed every collection. (bd436ef0d) (thanks @wikil!) - Client, client group and connection IDs now come from
crypto.getRandomValueswhen available instead ofMath.random. (e80d6305c, c2ebe3df5)
zero-cache Reliability
- A view-syncer that reloaded a client group's state after an error kept stale row records and query pipelines, which could crash it with
Query … is not in the CVRorUnexpected undefined value, or leave rows that never reached the client. (1137280b6, bb348961f, 9e0e85b62) - With a
statement_timeouton the CVR database, connecting a client group with many rows could fail withcanceling statement due to statement timeout. (892fa553b) - View-syncers on slow or throttled disks spent up to a minute inside a single commit running SQLite checkpoints, and were dropped by the replication-manager as slow subscribers. Serving replicas are now checkpointed by a separate process. (fbdb27ed5)
- Clients were disabled and reloaded with
SchemaVersionNotSupportedwhile an upstream migration backfilled a table or column. They now retry with backoff until the backfill is done. (8280af82f) - Malformed client messages, such as a second
initConnectionon a socket, an invalidbaseCookieor an invalid query registration, were logged as internal errors. They are now client errors logged atwarn. (e1c39de03, f452e99b5, 512baff4b) - A client group stays with the user it was first opened for across view-syncer restarts; a connection as a different user is refused the same way it already was while the group was live. (8d9466b13)
- Inspector authentication is per connection, and the 1.12 client remembers a password the server accepted, so it doesn't prompt again after reconnecting. Repeated wrong admin or operator passwords are now rate limited, on the inspector and on the HTTP diagnostic endpoints. (e186c0d59, c767ac31c, 65375a770)
zero-cachelogs and traces show a hash of theclientGroupIDinstead of the ID. (adfce3301, de9121d99)
Replication
- A
numeric,float4orfloat8value between about 2^63 and 1e21 stopped replication with aRangeError, anumericbetween 2^53 and 2^63 was stored as the wrong integer, and such a value in a row key or query literal failed the client group after a reload. These values now replicate as doubles. (340215ab5) - A
NULLelement in atimestamp[],json[]orint8[]column stopped replication, and in text, enum, boolean and numeric arrays it was stored as"NULL",falseornull. Array columns are now parsed correctly, including arrays with explicit bounds. Rows already stored keep their old values until they change. (fe4bc732a) - A schema-change or lag-report message on the replication stream that
zero-cachecould not parse stopped replication. Such messages, and schema-change messages that its own upstream functions didn't emit, are now skipped. (08c634549, f6010bb34) - With Litestream v5 backups on Google Cloud Storage or Azure, backup files were never compacted, so old ones were never deleted and the backup kept growing. (e2f030e63)
Breaking Changes
analyze-querybin removed:npx analyze-queryis gone. Use a script that callsrunAnalyzeCLI.@rocicorp/zeronow depends onoxc-parser, which installs a native binding for your platform. (a3932f51e, 02b6c1bbd)userIDneeds a token: whenZERO_AUTH_SECRET,ZERO_AUTH_JWKorZERO_AUTH_JWKS_URLis set, a connection that passes auserIDwithout anauthtoken is refused withUnauthorized. OmituserIDfor logged-out users; the deprecateduserID: 'anon'withoutauthis refused too. (522688120)- Changed defaults:
ZERO_UPSTREAM_PG_REPLICATION_SLOT_PER_REPLICAis on. It needs Litestream v5 backups, the default since 1.11, and each replication-manager holds its own slot while it runs. Turning it off doesn't resync. (49607b8b3) - Multi-node secret: set
ZERO_CHANGE_STREAMER_SECRETon the view-syncers first, then on the replication-manager. Until you do, the replication-manager still accepts view-syncers without it, but warns at startup. If one of your nodes runs sync workers and is also the replication-manager for other view-syncers, it now makes up its own secret, so set the secret on every node. (b2b8f68e4) - Legacy CRUD mutations are keyed by the primary key of the replica's table, not by the key the client sends. This only matters if your client schema declares a different primary key than the table has in Postgres. (f24fddae7)
- Rollback: clients keep their local database and unsent mutations when moving between 1.11 and 1.12 in either direction. One exception: a client group that registered an aggregate,
groupBy,distinctor JSON path query is reset whenzero-cacheis rolled back to 1.11, and its unsent mutations are lost. (8b716b3fe, dbf677638)