sundapeng opened a new issue, #9123:
URL: https://github.com/apache/paimon/issues/9123

   This is a design discussion, not a patch. The first four PRs of the stack in 
section 7 are open (#9119, #9120, #9121, #9122); the rest are held until the 
contract and the protocol below get a ruling.
   
   ## 1. Problem
   
   A catalog-managed format table registers its partitions with the catalog, 
and the catalog returns
   those registrations to every engine that plans a query against it. Today it 
returns no statistics
   for them — and worse than *no*, it returns numbers that look real.
   
   Two things are wrong, and they are independent:
   
   **(a) The contract does not say what a negative statistic means.** 
`PartitionStatistics` is read on
   two planes. On the *delta plane* — what a commit changed — a negative value 
is a decrement the server
   adds to what it holds; that is what the current javadoc describes. On the 
*observation plane* — what
   `listPartitions` returns for a partition as it stands — a negative value can 
only mean "nobody ever
   reported this". The javadoc covers only the first, so consumers have been 
reading the second as if it
   were the first, or as if unknown were zero.
   
   Treating unknown as zero is not a cosmetic error. A planner that reads zero 
rows plans against an
   empty partition that may hold a billion. An aggregate that answers 
`COUNT(*)` from a zero row count
   returns zero for a partition full of data, without touching a file and 
without any error — this has
   happened, in `apache/paimon-rust` #624.
   
   **(b) There is no channel for the numbers at all.** A format table has no 
snapshot, so
   `commitSnapshot` — the channel a table snapshot uses to report statistics — 
does not exist for it.
   Meanwhile the write path already has the numbers and throws them away: the 
rolling writer counts
   every row it writes, the single-file writer knows the byte length of the 
file it closed, and
   `prepareCommit` wraps each committer in a `TwoPhaseCommitMessage` that 
carries neither.
   
   So the numbers exist, cost nothing to collect, and have nowhere to go.
   
   ## 2. Non-goals
   
   - **Column statistics.** This is partition level only.
   - **Table-level statistics.** A format table has no snapshot to carry them.
   - **Changing the delta plane.** Negative deltas stay exactly as they are.
   - **Deciding which partitions exist.** Statistics never create or remove a 
partition row.
   - **Deriving statistics from a diff against object storage.** The number of 
live files a table
     semantically holds and the number of objects a bucket physically holds are 
different quantities;
     their difference must not drive deletion.
   
   ## 3. The contract
   
   | Plane | Where it comes from | Negative means | Zero means |
   |---|---|---|---|
   | Delta | a commit reporting what it changed | a decrement to apply | no 
change |
   | Observation | `listPartitions` | never measured (`UNKNOWN`) | an exact 
zero |
   
   Three rules follow, and all three matter:
   
   1. **Unknown is per field.** A reporter that can measure the file count but 
not the row count leaves
      the row count unknown and fills the rest. It must not have to choose 
between reporting a guess and
      reporting nothing.
   2. **Unknown is not zero.** An unreported partition is not an empty 
partition. Anything that filters,
      sums or timestamps a statistic has to distinguish them.
   3. **The fields stay primitive.** Boxing them to express unknown as `null` 
would break a `@Public`
      class, and the encoding above needs no new type.
   
   `PartitionStatistics.UNKNOWN` names the canonical value and `isKnown(long)` 
tests it, so callers stop
   comparing against `-1` at each site.
   
   ## 4. Reporting semantics
   
   Which mode a report uses follows from what the reporter did, not from a 
preference:
   
   | Write | Mode | Target partitions | Why it is exact |
   |---|---|---|---|
   | append | **ADD** | the ones written | the writer saw only its own files, 
so it can only report an increment |
   | dynamic overwrite | **SET** | the ones written | the write replaced 
everything those partitions held, so what it wrote *is* the total |
   | static prefix overwrite | **SET** | the whole cleared subtree ⊋ the ones 
written | same, plus partitions this commit wrote nothing to |
   
   **Static prefix overwrite is why a pure increment cannot express this.** 
Clearing a prefix empties
   every partition beneath it, including ones the commit writes nothing to. 
Their old data is gone and
   no increment says so. They report zero — an exact zero, they really are 
empty — and **stay
   registered**. The directories emptied that way come out of the deletion 
listing, which already had to
   walk them, so those numbers cost no extra IO.
   
   Per field, per mode:
   
   | Field | ADD | SET |
   |---|---|---|
   | `recordCount` / `fileSizeInBytes` / `fileCount` | summed | replaced |
   | `lastFileCreationTime` | max of stored and reported | replaced |
   | any field reported unknown | skipped | skipped |
   
   A timestamp is not a quantity: summing two epoch-millisecond values produces 
a meaningless number, so
   ADD takes the later of the two. SET may move it backwards, which is correct 
— the reporter saw the
   whole partition, so the newest file it found is the newest there is.
   
   ### 4.1 ADD is not idempotent, and this RFC does not pretend otherwise
   
   A redelivered ADD counts twice, and nothing in the report lets the server 
tell a redelivery from a
   second genuine increment. Three sources of drift, none of which this closes:
   
   | Source | Note |
   |---|---|
   | a redelivered request | the RPC succeeded but the response was lost |
   | a writer that is not Paimon | files appear that no commit reported |
   | a file deleted out of band | files disappear that no commit reported |
   
   **Convergence is a later full report over the same partition** — which is 
what the two management
   commands in §5 are for. A deduplication token in front of every commit would 
need server-side state
   with its own lifecycle, and would cost more than the drift does. **This is a 
trade-off, not an
   oversight**, and it is the open question most worth a maintainer's opinion 
(§8).
   
   What this RFC *does* close is the case where the client causes the 
duplication by itself: a POST
   carrying a non-empty ADD report declares itself unsafe to replay, so the 
retry that a 429 or a 503
   triggers cannot count the same increment twice. See §6.
   
   ## 5. Where the numbers come from
   
   | Source | Row count | File count / bytes / time | Extra IO | Covers |
   |---|---|---|---|---|
   | commit | **exact**, already counted | exact, already known | **none** | 
only what Paimon wrote |
   | `MSCK REPAIR TABLE` | — (listing only) | exact | O(files) listing | 
everything, whoever wrote it |
   | `ANALYZE TABLE` | exact where a footer exists | exact | listing + footers 
| everything |
   
   **The commit is the only place a row count is free.** A rescan pays a file 
footer for it, and only
   formats that carry one can give it at all: a CSV, TEXT or JSON partition 
keeps an unknown row count
   rather than a guessed one. One unreadable footer makes the whole partition's 
row count unknown rather
   than short — a sum missing a file, reported as exact, is worse than no 
number.
   
   **Both management commands measure only partitions that are already 
registered**, and both replace
   rather than accumulate, so running either twice is running it once. 
Measuring must never register a
   partition the command was not asked to.
   
   **Both are opt-in, for the same reason**: measuring changes what the command 
costs. A plain repair
   lists partition directories; measuring lists the files inside every one of 
them. That is a different
   order of magnitude on a table with many partitions, and a command should not 
silently become that.
   
   A listing failure aborts the whole collection rather than reporting what it 
managed to see: a
   truncated listing is indistinguishable from a partition that lost files.
   
   ## 6. Protocol
   
   The registration request carries the statistics. Two optional fields on the 
existing
   create-partitions request — the statistics list and the mode — and nothing 
else.
   
   **Why not a separate endpoint.** Registration and statistics then land in 
one request and one
   server-side transaction. "The statistics failed but the partition 
registered" stops being a state
   anyone has to handle, and no extra round trip is paid. A separate endpoint 
buys exactly one thing:
   the ability to report statistics without registering — which is the state 
worth *not* having.
   
   **Compatibility runs both ways and neither direction errors.**
   
   | | Behaviour |
   |---|---|
   | new client, old server | the field is ignored; statistics stay unknown; 
the client logs once that the server did not accept them |
   | old client, new server | no statistics arrive; the columns stay unknown |
   | a caller that reports nothing | sends exactly the request it sends today, 
so the server sees no change in shape |
   
   The asymmetry to be explicit about: **an observation that is missing is 
something consumers can
   handle; an observation that is wrong is not.** Silently dropping statistics 
is an acceptable
   degradation *only because* §3 makes unknown expressible. Without §3 the same 
silent drop would leave
   a fabricated zero behind, and would have to fail closed instead. **§3 is a 
prerequisite for §6, not a
   parallel change.**
   
   ### 6.1 Retry
   
   `RESTRequest` gains `isRetrySafe()`, defaulting to `true` so every existing 
request keeps the 429/503
   retry it has today — that retry is the only defence against a rate limiter 
or a restarting node, and
   nearly every request Paimon sends over POST is idempotent by content even 
though POST is not
   idempotent by method.
   
   A request answering `false` is sent exactly once and the failure reaches the 
caller. The mark travels
   in the client context rather than in the request, so it never reaches the 
wire and survives whatever
   the exec chain does to the request object; the getter is annotated so it 
stays out of the serialized
   body as well.
   
   ## 7. PR stack
   
   One diff would put a contract clarification, an internal data-flow change, a 
wire change, a
   behaviour change and two management commands in front of one reviewer. Eight 
PRs instead:
   
   | # | Title | Depends on | Reviewable on its own because |
   |---|---|---|---|
   | S0 | `[core]` Spell out what a negative partition statistic means | — | 
contract + named constant, zero behaviour change |
   | S1 | `[core]` Carry the row count and byte size a format table writer 
already counted | S0 | paimon-core internal, nothing reads the numbers yet |
   | S2 | `[rest]` Do not replay a POST the server cannot absorb twice | — | 
opt-in per request, default keeps today's behaviour |
   | S3 | `[core][rest]` Report partition statistics when registering 
partitions | S0, S2 | new API is all defaults and overloads |
   | S4 | `[core]` Report what a format table commit wrote to the catalog | S0, 
S1, S3 | **the only behaviour change**, behind a flag, off by default |
   | S5 | `[core][spark]` Measure format table partitions in MSCK REPAIR TABLE 
| S0, S3 | off by default |
   | S6 | `[spark]` Support ANALYZE TABLE … COMPUTE STATISTICS on 
catalog-managed format tables | S5 | a different command with a different cost 
model |
   | S7 | `[spark]` Cover the partition operations left over | — | tests plus 
one error message |
   
   ```
   S7 ──────────────────────────────────────────► independent
   S0 ──┬──► S1 ──┐
        │         ├──► S4
   S2 ──┴──► S3 ──┴──► S5 ──► S6
   ```
   
   Two orderings are not negotiable:
   
   1. **S2 before S3.** A non-empty ADD report is not retry-safe. With S3 in 
and S2 out, an automatic
      retry reports the same increment twice and the server cannot tell.
   2. **S4 alone.** It is the only PR that changes what an existing write does.
   
   If S3–S6 are rejected, S0 still stands on its own and is worth having: the 
ambiguity it removes has
   already produced one silent wrong answer.
   
   ## 8. Open questions for maintainers
   
   1. **Is "ADD may drift, converge with a later SET" acceptable**, or should 
the protocol carry an
      idempotency token from the start? This decides whether S3/S4 are shaped 
as they are. §4.1 argues
      for accepting the drift; the counter-argument is that a wrong number in a 
catalog is a wrong number
      regardless of how it got there.
   2. **Should the client be able to tell that a server ignored its 
statistics**, beyond logging once?
      A capability flag in the response is the obvious answer, and the obvious 
cost is one more thing to
      version.
   3. **Should `lastFileCreationTime` come from the commit clock or from each 
file's mtime?** The clock
      is free; the mtimes cost one request per file and give a coarser answer 
for a partition written
      over a long window. Currently the clock.
   4. **A partition emptied by a static prefix overwrite keeps its old 
`lastFileCreationTime`** (SET
      only moves it forward under ADD, and there is no file to date under SET). 
Known conservative
      choice — worth a second opinion.
   
   ## 9. Verification matrix
   
   Synthetic values throughout; a partition written by a single commit of 3 
rows into one file.
   
   | Scenario | Expected |
   |---|---|
   | registered, never reported | every field unknown, per field |
   | append 3 rows, then 2 more | ADD → 5 rows, 2 files |
   | dynamic overwrite writing 4 rows over those 5 | SET → 4, **not 9** |
   | static prefix overwrite, sibling partition cleared but not rewritten | 
sibling → exact 0, **still registered** |
   | report all zeros | partition still returned by `listPartitions` |
   | CSV table, rescan | file count / bytes / time filled, row count still 
unknown |
   | one unreadable footer in a partition | that partition's row count unknown, 
not short |
   | listing fails mid-rescan | whole collection aborts, nothing written |
   | ADD reported, response lost, request retried | sent once; the failure 
reaches the caller |
   | repeat the same SET | idempotent |
   | `DROP PARTITION` | row gone; statistics go with it; no separate correction 
needed |
   
   
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to