Repository navigation
Discussion: would an ordered (memcomparable) __pgt_row_id be worth the migration?
#844
Replies: 7 comments 4 replies
|
Thanks for the detailed write-up — the two cost drivers you identified (per-row hash CPU and random index access) are real and structural, and the profiling story makes sense. A few thoughts from looking at how pg-trickle is currently built: The ordering benefit is real, but has an upper bound The index locality win only materialises once the stream table outgrows
It's documented as The keyless (all-columns) case is concerning For stream tables without a primary key, the row ID currently hashes all output columns into a single BIGINT. A memcomparable encoding of, say, 20 text columns could be very wide. The index size penalty in that case could easily outweigh the ordering benefit. Combined join IDs need a delimiter scheme Join row IDs currently combine two child IDs by hashing them together ( The question we'd like your take on: would opt-in per stream table work for your use case? The
The implementation surface is bounded: the Does opt-in cover your actual use case, or do you need the ordering behaviour to apply uniformly across all tables for the workload you have in mind? Happy to discuss a concrete spike design if the opt-in framing works. |
|
We have turned this discussion into a concrete proposal: [PROPOSAL_VERSIONED_ROW_IDENTIFY_ENCODING.md](https://github.com/trickle-labs/pg-trickle/blob/main/proposals/PROPOSAL_VERSIONED_ROW_IDENTIFY_ENCODING.md. Our current recommendation is to adopt ordered, canonical Because PostgreSQL cannot index arbitrarily large This is intentionally a breaking, extension-wide migration. Existing stream tables and CDC state must be rebuilt, so retaining WDYT? |
|
It might be an option to keep the BIGINT support as a pragmatic legacy/default mode with documented limits. BIGINT is reasonable for existing users and small-to-medium workloads because xxhash is deterministic, fast, and collisions are unlikely at modest row counts. But it is not collision-proof. |
|
It think this summarizes the situation well:
|
|
Thanks for writing this up — I'm in favor of the full V2 cutover. My main argument against a BIGINT legacy mode is engineering cost: with two identity representations, every differential path — CDC buffers, joins, aggregates, MERGE, migration state — has to be adapted to both, and that cost is permanent. A one-time rebuild with clear migration documentation for existing users seems much cheaper than carrying two modes forever. For the ordering benefit, I'd suggest profiling before we take it for granted: capture flamegraphs on the current MERGE path to confirm the hashed row ID actually causes the index cache/localization problem on large tables, then measure the same workload under V2 to show the ordered encoding delivers a real improvement. That would turn §13's expectations into evidence. |
|
Good to hear that. The proposal will be implemented throughout the v0.87.15-v0.87.17 roadmap versions. The final proposal can be found in proposals/PROPOSAL_VERSIONED_ROW_IDENTIFY_ENCODING.md. NOTE: This is a breaking change and all stream tables will be automatically recreated when upgrading to v0.87.17. No in-place migration is planned. |
|
v0.87.17 has been released now. |
Uh oh!
There was an error while loading. Please reload this page.
Discussion: would an ordered (memcomparable) byte encoding for
__pgt_row_idbe worth the migration?Opening this as a discussion rather than a formal proposal — we've been running a similar technique in our differential-refresh engine and it looked promising enough to bring back here.
Why bring this up
Both pg-trickle and our engine identify rows via a hash and index on it. We took the same conceptual path originally, and from profiling our differential-refresh hot path two costs sat at the top in a way that no further pipeline tuning was going to move — and both turned out to come from the choice of hash as the row identity, not from anything specific to our codebase:
1. Per-row CPU. Producing a hash from typed column values requires turning them into a hashable byte sequence first — per column, per call. The same row-id expression typically appears at several stages of a differential pipeline, so the per-row cost compounds with pipeline depth. The exact constants depend on how each implementation converts values to bytes; the shape of the cost is shared.
2. Random index access. A hash output is uniformly distributed by construction, so the index on the row-id column is uncorrelated with the physical write order of the base table. MERGE-time probes hit random index pages, and the buffer pool can't keep the working set warm when the storage table outgrows
shared_buffers. This one is purely structural — it follows from "row ID = hash" regardless of which hash function or storage layout is in use.These observations come from our engine, so we won't claim the constants carry over to pg-trickle directly. But the mechanism is shared, and that's what motivated us to look for an alternative to hash-based row identity in the first place.
The idea
Replace the hash with a memcomparable byte encoding of the key columns, stored as
__pgt_row_id BYTEA. The defaultbytea_opsBTREE then gives an order-preserving index for free — index access pattern follows the base PK order instead of being scrambled.The encoding is simple — one per-type appender that writes bytes whose
memcmporder matches the natural value order of the source type:\0terminator (PG text disallows embedded NUL, so the terminator delimits adjacent columns undermemcmp).Up front: this is the one direction we've actually built. If anyone in the community has tried a better encoding in a similar setting we'd genuinely like to hear about it before pg-trickle commits to anything. Memcomparable bytea is a known-good baseline, not necessarily the optimum.
The encoder function's own performance is the part that matters most. Whatever encoding wins on paper, the function runs once per row per CTE stage on every refresh — algorithmic elegance doesn't help if the implementation allocates or does fmgr lookups on the hot path. The shape we'd want:
record, notVec<String>. Resolve the record'sTupleDesconce, deform the tuple directly. No::TEXTcast, noVec<Option<String>>allocation.How it would slot into pg-trickle
The existing abstractions hold.
RowIdStrategy/RowIdSchemainsrc/dvm/row_id.rsdecides which columns participate in the identity — that contract doesn't change. The only thing that changes is the kernel behind the__pgt_row_idcolumn:pg_trickle_hash_multi(ARRAY[col::TEXT, ...])encode_row_id(ROW(col, ...))__pgt_row_id BIGINT UNIQUE INDEX__pgt_row_id BYTEA UNIQUE INDEX(defaultbytea_ops)ON CONFLICT (__pgt_row_id)/MERGE ON ... = ...UNIQUEUNIQUE— encoding is injectiveThe
RowIdSchemaplan-time guardrail doesn't care whether the kernel is xxh3 or memcomparable bytes; it checks column membership, not encoding.Open questions
We don't have confident answers for these, and they probably gate the conversation:
Is
__pgt_row_idan external contract? It's observable to the DuckLake sink, the outbox publisher, and any dbt model built onpgtrickle.pgt_*tables. Changing the column type fromBIGINTtoBYTEAis a wire-format break, and if downstream consumers parse it, this question constrains the rest of the design before the technical discussion even starts.Type coverage policy. Memcomparable encoding is intrinsically per-type. A whitelist (bool, int2/4/8, oid, date, time, timestamp(tz), uuid, text/varchar/bpchar) covers the PKs we've seen in practice, but pg-trickle today accepts anything that can be
::TEXT-cast. Reject unsupported types at CREATE time, or fall back to hash for them? Both are defensible; the trade-off is "fewer supported workloads, simpler invariants" vs "broader coverage, two code paths".Is bytea memcomparable the right encoding? We validated it works; we haven't compared it against alternatives. If the community has experience with a better-suited approach we'd rather build on that than re-derive a worse-than-known result.
What we're asking for
Mostly: does this direction sound interesting enough to validate against pg-trickle? A small spike against a representative workload should be enough to tell whether the win carries over from our setting — pg-trickle's pipeline has different cost weights, and we'd much rather have a real measurement on real pg-trickle than port any number from elsewhere.
We're happy to share the encoder shape, the call-site caching pattern, or a microbenchmark harness in whatever form is most useful — or to put up a draft PR if maintainers prefer something concrete to react to.
All reactions