Compare commits

...
Author SHA1 Message Date
Chris Lu 12251e7457 docs(parquet-design): rewrite plan code-layout and milestones
Realign with the new catalog-server + volume-server architecture.

Code layout:

- Catalog-side code lives at weed/s3api/iceberg/pushdown/ (the
  existing catalog-server tree). Houses the service entry, request
  validation, footer cache, predicate decoders, planner, registry,
  and Iceberg delete metadata tracking.
- Volume-side code (lands in Phase 3) lives at weed/storage/pushdown/
  with a volume_grpc_pushdown.go entry point, plus index/* readers
  and exec/* (predicate eval, vector compute, bitmap merge).
- Proto stays shared at weed/pb/parquet_pushdown.proto.
- Note: existing M0/M1 code at weed/parquet_pushdown/ moves in
  M2-rebase, the first task of the revised milestones.

Milestones:

- "Already shipped" section records M0/M1 status.
- M2-rebase: relocate package, drop standalone-daemon command and
  daemon/ subpackage, register service on s3api gRPC.
- Phase 1 (catalog candidates): M3 row-group pruning, M4 catalog-
  validated trust mode.
- Phase 2 (catalog index registry): M5 schema/CRUD, M6 Iceberg
  delete metadata, M7 background reconciler.
- Phase 3 (volume execution): M8 bloom, M9 bitmap+btree, M10 page,
  M11 delete bitmaps, M12 vector IVF, M13 hybrid scalar+vector.
- Phase 4 deferred to measurements.
- Connector integrations run in parallel with Phases 1-3.
2026-04-25 23:18:31 -07:00
Chris Lu 5016aad2c0 docs(parquet-design): plan residency switches to catalog + volume tiers
Decision #1 reverses again: pushdown is *not* a standalone weed
pushdown daemon. Planning lives inside the existing catalog-server
role (today colocated with the S3 gateway's Iceberg REST handlers in
weed/s3api/iceberg/), so the planner can reuse the table /
snapshot / file-list state the catalog already manages. Heavy
execution lives on the volume servers next to the data, so one busy
table cannot bottleneck cluster-wide planning.

Update both the architectural-decisions bullet and the Decisions
Recorded entry. Note that an optional query-worker tier remains
available as the design's Phase 4 contingent on measurements.

Code-layout, milestone, and existing-M0/M1-code revisions land in
follow-up commits.
2026-04-25 23:13:36 -07:00
Chris Lu 36832a89f5 docs(parquet-design): rewrite Rollout Plan as 4-phase catalog-then-volume
Replace the six-phase feature-by-feature rollout (metadata, scalar
indexes, page-level, deletes, vectors, connectors) with a four-phase
plan that follows the new tier split:

  Phase 1: catalog server returns candidate files/row groups/ranges
           using only Iceberg metadata + parsed Parquet footer
  Phase 2: catalog server tracks the side-index registry, including
           Iceberg delete metadata; planner consults registry,
           reconciler keeps it honest
  Phase 3: volume servers execute local index lookup, scalar
           predicate eval, vector distance + local top-K; catalog
           plan can reference volume-server endpoints
  Phase 4: optional standalone query workers, only if measurements
           justify them

Connector integrations move out of phase numbering and run in
parallel with Phase 1-3 once the catalog API stabilizes; each
connector picks per-request between the byte-range path and the
volume-server-execution path.
2026-04-25 23:12:59 -07:00
Chris Lu af4b772c03 docs(parquet-design): two-tier catalog brain + volume hands architecture
Replace the single-pipeline architecture diagram with a two-tier
split that maps onto SeaweedFS's existing roles:

- Catalog server is the brain: table discovery, snapshot resolution,
  Parquet file list, side-index registry, freshness tracking, and
  planning. It returns plans, never raw bytes.
- Volume servers are the hands: range reads (today) plus local
  side-index lookup, scalar predicate evaluation, vector distance
  compute, and local top-K (new). Heavy execution lives next to the
  data so one busy table cannot bottleneck cluster-wide planning.
- S3 gateway stays the compatibility path for unmodified readers
  and never participates in pushdown planning.

Update the Index Residency section to match the split: the catalog
holds the index *registry* (small per-file metadata about where each
index lives, identity, freshness), volume servers hold index
*payloads* (the actual .index/ files next to data needles). Spell
out failure modes for each tier: missing/stale payload downgrades
the plan, stale registry triggers reconciliation, catalog
unavailable falls back to S3, volume unavailable falls back to
standard SeaweedFS replication. Stats indexes_used/missing surfaces
degradations to the connector.

Update the access-modes section to reflect that the response always
includes byte ranges fetchable through S3 (so connectors that don't
want to chat with volume servers still get pruning), with volume-
server-side execution opt-in for hot paths (vector ANN, large
bitmap unions).
2026-04-25 23:12:12 -07:00
Chris Lu 5d59db0503 parquet_pushdown(M1-C5): round-trip integration test
Generates a 3-file Parquet "table" in a temp dir using parquet-go,
boots SeaweedParquetPushdown on a bufconn listener with LocalLoader,
and submits real RPCs covering the M1 surface:

- TestPushdown_M1_RoundTrip: predicate-less, 2-column projection
  across 3 files. Asserts FileRanges reference the right file and
  fit inside it; first call has zero hits and one miss per file.
- TestPushdown_M1_FooterCacheHits: a second identical request must
  produce one cache hit per file and zero misses, the dev plan's
  acceptance bar for cache hit-rate.
- TestPushdown_M1_NoColumnProjection: empty Columns returns ranges
  for every column in every row group.
- TestPushdown_M1_UnknownColumn: unknown column path is rejected.
- TestPushdown_M1_FieldIdOnlyRejected: field-id without a path hint
  is rejected (catalog-schema resolution lands in M3).

Filer-process-level integration is deferred: M1 does not require a
filer, and the bufconn round-trip exercises every M1 code path end-
to-end. test/parquet_pushdown/ stays available for the heavier
filer-driven test that lands in or after M2.
2026-04-25 15:20:08 -07:00
Chris Lu e1a09416fc parquet_pushdown(M1-C4): file/range-level pushdown for predicate-less requests
Wire the parser, cache, and Loader through the Service:

- Options gains Loader and Cache (with CacheSize fallback). Defaults
  to LocalLoader and a footer-cache of defaultFooterCacheSize entries.
- Pushdown handles the predicate-less, no-vector path by parsing each
  DataFile's footer (cached by Identity = (Path, SizeBytes,
  RecordCount, ETag)) and emitting one FileRange per (data file, row
  group, projected column) triple. Predicate / vector requests still
  return Unimplemented with stats.
- statsRecorder gains per-request footer cache hit/miss counters that
  populate PushdownStats.footer_cache_hits / footer_cache_misses.
- Column projection matches by ColumnRef.Path. Field-id-only refs
  return InvalidArgument until M3 (catalog-validated mode) plumbs
  the Iceberg schema through; the path-hint fallback is enough for
  the M1 round-trip test.

Daemon supplies LocalLoader for now; the filer-backed Loader lands
when M2+ needs it.

Update the M0 Unimplemented test to send a predicate so it exercises
the still-Unimplemented branch (file/range-level pushdown is now
implemented for the no-predicate case).
2026-04-25 15:18:16 -07:00
Chris Lu 737cc0f330 parquet_pushdown(M1-C3): add Loader interface and LocalLoader
Introduce a small Loader interface so the service can stay oblivious
to the storage backend behind a Parquet path: tests inject a
LocalLoader backed by os.File, and the daemon will plug in a
filer-backed Loader in a later milestone.

FileHandle is the parser-friendly view: io.ReaderAt + io.Closer +
Size(). LocalLoader rejects missing files and directories up front so
service-level error paths see a clear "open failed" rather than a
later ReadAt EOF.

The Service does not yet consume Loader — that wiring lands in M1-C4
along with the predicate-less Pushdown handler that actually issues
loads.
2026-04-25 15:15:46 -07:00
Chris Lu ca42c5bbe4 parquet_pushdown(M1-C2): add footer LRU cache
Add weed/parquet_pushdown/footer/cache.go: a concurrent LRU keyed by
the Identity tuple (Path, SizeBytes, RecordCount, ETag) the design
specifies in Index Consistency. Mtime is intentionally absent.

Notable behavior:

- size <= 0 is rejected at construction so a misconfiguration cannot
  silently disable caching by sizing the LRU to zero.
- Hits / misses are tracked atomically and surfaced via Stats() so
  the service handler can stamp PushdownStats.footer_cache_hits /
  footer_cache_misses.
- GetOrLoad is a non-singleflight load helper. Concurrent loads for
  the same identity may both call the loader; that's an acceptable
  starting point given footer parses are millisecond-scale.
  Singleflight is deferred until profiling shows it matters.

Tests cover: zero-size rejected, hit/miss counters, identity is a
composite key (same path, different size = different entry),
LRU evicts above cap, GetOrLoad caches the result and dedupes
subsequent calls, errors propagate without inserting, and the cache
is safe under concurrent readers (-race).
2026-04-25 15:14:56 -07:00
Chris Lu d947f19f33 parquet_pushdown(M1-C1): add footer parser
Add weed/parquet_pushdown/footer/parser.go: a thin wrapper around
parquet-go's OpenFile that returns the cache-friendly subset M1
needs — per-row-group, per-column-chunk byte ranges plus row counts
and dotted column paths.

Notes:

- SkipPageIndex(true) and SkipBloomFilters(true) are passed because
  M1 only does file/range-level pushdown; ColumnIndex/OffsetIndex
  consumption is M6 work and skipping them avoids the extra
  thrift-decode pass per parse.
- columnChunkBytes prefers DictionaryPageOffset when present so the
  byte range covers both the dictionary page and the data pages of
  the column chunk, per the design's Page-Level Index rule about
  dictionary pages.

Tests cover row count, column count, column paths, byte-range
sanity, and bad-input rejection.
2026-04-25 15:13:45 -07:00
Chris Lu 8fe0b68b7c parquet_pushdown: distinguish "no score" from Score == 0 in ScoredRowRef
A bare float32 score conflates "this row has no score, it came from
a scalar predicate" with "this row's vector similarity score is
0.0", which is a legal value: zero L2 distance and zero dot product
both arise in real queries. Mixing scored and unscored results in
one response (e.g. hybrid scalar+vector) becomes ambiguous.

Add has_score (proto field 2, sandwiched between Ref and Score) and
the corresponding HasScore field in the design's Go sketch. Update
the prose paragraph to call out the new flag's purpose.
2026-04-25 15:09:15 -07:00
Chris Lu 6c826e710a docs(parquet-design): document page-pruning consumption contract
A bare list of page byte ranges is not consumable by every Parquet
reader — it requires a page-aware decoder (parquet-go's
ColumnChunkReader.ReadPage, Arrow's PageReader, DuckDB's internal
reader). Stock S3-aware clients only support row-group reads, and
feeding them a page byte range as if it were a row-group range
produces undefined output.

Spell out the two supported consumption modes:

- page-aware reader: consume Pages directly, fetching the dictionary
  page region alongside as already merged in;
- row-group reader: ignore Pages, fall back to RowGroups, optionally
  request a row-selection vector to apply page-level filtering
  post-decode.

Note that v1 returns Pages only and the row-selection-vector output
is a documented future revision (post-M6) to keep the wire shape
simple now.
2026-04-25 15:07:55 -07:00
Chris Lu 9561d95507 parquet_pushdown: predicate must be a bound expression with schema id
Without naming a schema, a serialized predicate's column references
are ambiguous: the same field name can refer to different field IDs
across schema-evolution events, and Substrait field-reference IDs
mean nothing without saying which schema they index into.

Make the request shape unambiguous:

- Add ParquetPushdownRequest.SchemaId (Iceberg schema id) to the
  proto and to the design's API sketch.
- Document the binding contract in the design: the predicate must
  carry only field-id references; the connector binds before
  sending, the server checks ids against the schema.
- Validation now rejects (predicate set, schema_id == 0) with
  InvalidArgument before any work is done. Deeper "every field id
  exists in the snapshot's schema" check needs catalog access and
  lands in M3.
- Update / add tests covering the new gate.
2026-04-25 15:07:12 -07:00
Chris Lu 5fec310f73 docs(parquet-design): per-pair equality bitmap is reusable across snapshots
The previous text said equality-delete bitmaps "cannot be precomputed
as a static bitmap per data file" because the applicable set changes
across snapshots. That is true for the *union*, but it conflates the
union with the per-pair building blocks.

Split the cache layer:

- per-pair bitmap (data_file × one equality-delete file) is purely a
  function of the two immutable files; build once, cache durably,
  reuse across snapshots, sessions, and queries.
- per-query response unions the per-pair bitmaps that apply at this
  snapshot. New snapshots only require building per-pair bitmaps for
  newly added delete files, not recomputing existing ones.

Drop the snapshot_id from the cache key (it was the wrong key) and
keep eviction tied to file identity going out of scope.
2026-04-25 15:05:48 -07:00
Chris Lu ac49cfd59b docs(parquet-design): equality deletes are key-row sets, not predicates
The previous "Equality deletes carry a predicate (e.g. id = 42)"
framing is misleading. Iceberg equality-delete files are not
serialized predicates — they are stored row sets keyed on a fixed
set of columns named by the manifest's equality_ids list. A data row
is deleted if its values on those columns match any delete-file row.

Replace the predicate framing with an execution model that:

- reads only the equality-key columns from the data file;
- loads the equality-key rows from each applicable delete file;
- joins on the key tuple using Iceberg's equality semantics
  (NULL matches NULL, Iceberg-defined NaN handling, canonical
  encoded comparison for decimals / timestamps / dates).

Forward-link to a not-yet-added cache-reusability subsection that
the next commit will make explicit.
2026-04-25 15:05:21 -07:00
Chris Lu 651a72e803 docs(parquet-design): filter position-delete rows by file_path before merge
Position-delete files come in two shapes: single-target (one data
file named via ReferencedDataFile) and multi-target (one delete file
covers many data files; each row carries its own file_path column).
The previous prose said "merges that set into a per-data-file
roaring bitmap" without spelling out the multi-target case, which
would over-delete by attributing every row in a multi-target delete
file to every applicable data file.

Add a bullet pair making the row-level file_path filter explicit:
single-target rows all contribute, multi-target rows only contribute
when file_path == data_file.Path.
2026-04-25 15:04:57 -07:00
Chris Lu d1236ebf4f parquet_pushdown(M0): cover daemon Config validation, especially connector-trusted gating
The connector-trusted trust mode is the dev-only escape hatch and
must require both -trust=connector-trusted *and*
-dev.connector_trusted_ack. The latter is intentionally awkward to
flip so an operator cannot disable catalog validation by accident.

Add unit tests covering: default ok, port range, missing filer
addresses, connector-trusted without ack rejected, connector-trusted
with ack accepted, unknown trust modes rejected, and that the error
message names the ack flag so the operator knows how to proceed.
2026-04-25 13:44:36 -07:00
Chris Lu fe7afc9983 parquet_pushdown(M0-C6): add bufconn integration test
Stand up the SeaweedParquetPushdown service on an in-process gRPC
bufconn listener and exercise it from a real gRPC client. Validates
the M0 acceptance bar end-to-end without needing a filer:

- TestPing_ReportsConfiguredVersionAndTrustMode: Ping returns the
  daemon's configured version and trust mode.
- TestPing_DefaultsTrustModeToCatalogValidated: zero-Options Service
  defaults to catalog-validated.
- TestPushdown_ReturnsUnimplementedWithStats: Pushdown returns
  Unimplemented with a PushdownStats attached as a status detail,
  with trust_mode populated and server_time_micros non-negative.
  This is the M0 acceptance criterion from PARQUET_PUSHDOWN_DEV_PLAN.md.
- TestPushdown_RejectsInvalidRequestBeforeUnimplemented: validation
  fires before the Unimplemented path; an empty data_files request
  returns InvalidArgument.

A heavier filer + daemon process-level integration test is deferred
to test/parquet_pushdown/ in a later milestone; the dev plan's
filer-startup phrasing covers ground M3 needs anyway, and bufconn is
sufficient to lock in the wire format for M0.
2026-04-25 13:44:10 -07:00
Chris Lu 52f39d6e86 parquet_pushdown(M0-C5): add weed pushdown.ping smoke command
Add weed/command/pushdown_ping.go that dials a pushdown daemon over
gRPC, calls Ping with a 5s timeout, and prints the daemon's reported
version and trust mode. Used both as a deployment smoke test and by
the M0 integration test.

Default -server=localhost:18888 matches the daemon's default listen
port. -tls toggles between insecure and the security/grpc.client
TLS config; default insecure mirrors the iam command's pattern for
local-development smoke tests.

Naming follows the existing dotted convention (mq.agent, mq.broker,
mq.kafka.gateway): the daemon is "weed pushdown", the smoke test is
"weed pushdown.ping".
2026-04-25 13:43:01 -07:00
Chris Lu 416040940b parquet_pushdown(M0-C4): wire the weed pushdown subcommand
Add weed/command/pushdown.go that parses CLI flags (-filer, -ip,
-port, -trust, -dev.connector_trusted_ack), assembles a daemon.Config,
and calls daemon.Run. Default port 18888 is unused elsewhere in the
SeaweedFS port table and is far enough above the filer/master/volume
ports to be unsurprising in operations. Long help spells out the two
trust modes and points the operator at PARQUET_PUSHDOWN_DESIGN.md and
PARQUET_PUSHDOWN_DEV_PLAN.md for context.

Register cmdPushdown in weed/command/command.go's Commands list so
"weed pushdown" appears in the top-level help. The command is gated
"<WIP>" until later milestones land actual pruning behavior.
2026-04-25 13:42:07 -07:00
Chris Lu e181e2c035 parquet_pushdown(M0-C3): daemon bootstrap (config, filer client, server)
Add weed/parquet_pushdown/daemon, the host process for the
SeaweedParquetPushdown service:

- Config carries bind ip/port, filer addresses, trust mode, and the
  ConnectorTrustedAck flag that gates the dev-only connector-trusted
  trust mode (default refuses it; explicit ack required).
- filerClient probes the filer with GetFilerConfiguration at startup
  so bad endpoints fail fast, mirroring the iam command's pattern.
  Bounded by a 60s timeout; later milestones use the same client to
  read Parquet data and side-index blobs.
- server.go wires it together: load TLS, probe filer, build the
  Service, listen on (host, localhost) gRPC ports, register the
  service + reflection on each, install grace.OnInterrupt for
  GracefulStop, and Serve.

Run() returns an error so the calling weed pushdown command can
report startup failures instead of glog.Fatalf.
2026-04-25 13:40:45 -07:00
Chris Lu e610dad2a1 parquet_pushdown(M0-C2): add service skeleton and request validation
Implement the gRPC service surface of the pushdown daemon:

- Service struct embeds the generated UnimplementedSeaweed
  ParquetPushdownServer, exposes Ping (returns version + trust mode)
  and Pushdown (returns Unimplemented after request-shape validation).
- request_validation.go enforces hard caps (max data files, columns,
  predicate bytes, row-id cap, vector dim, top_k) and content-type
  rules (Puffin DV requires blob_length + referenced_data_file;
  equality delete requires equality_field_ids; predicate_kind and
  predicate must be set together).
- stats.go is a small accumulator that produces a PushdownStats
  message stamped with trust_mode and elapsed micros; M0 wires it
  into the Unimplemented status's details so the stats wire format
  is exercised end to end.
- TrustMode constants encode the design's catalog-validated
  (default) and connector-trusted (dev-only) modes.

Unit tests cover the validation matrix (rejects empty/oversize
requests, accepts well-formed vector queries, accepts well-formed
deletion-vector descriptors, rejects DVs missing blob_length /
referenced_data_file).
2026-04-25 13:39:28 -07:00
Chris Lu 2e423c0f31 parquet_pushdown(M0-C1): add proto and generated bindings
Define the SeaweedParquetPushdown gRPC service with two RPCs (Ping,
Pushdown) and the request/response messages from the design's API
sketch (DataFileDescriptor, DeleteFileRef, ColumnRef, VectorQuery,
PushdownStats, ...). Includes the discriminator enums (DeleteContent,
FileFormat, PredicateKind, VectorMetric).

Add the Makefile rule mirroring the iam.proto / mq_agent.proto
pattern (paths=source_relative, output to weed/pb/parquet_pushdown_pb).
Commit the generated .pb.go and _grpc.pb.go alongside; the existing
plugin_pb / mount_peer_pb directories follow the same checked-in
generated-code convention.

No service implementation yet — that's M0-C2.
2026-04-25 13:36:41 -07:00
Chris Lu a315f78b91 docs(parquet-design): resolve open questions and unblock M0
Replace the Open Questions section with a Decisions Recorded section
capturing all ten resolutions, and flip the top-of-doc Status from
"draft, open questions remain" to "M0 unblocked".

Decision 10 (overhead budget) records the agreed ≤10% target and
adds a config knob (pushdown.max_overhead_pct, default 10) so
deployments can tighten or loosen the bound without a code change.
2026-04-25 13:28:12 -07:00
Chris Lu 929c33980f docs(parquet-design): align plan side-index path with .index co-location
Mirror the design's switch from a separate-namespace path to a
co-located hidden .index/ directory next to the Parquet data, so the
plan and the design agree on file-scoped and folder-scoped layouts
and on the configurable-hidden-prefix fallback.
2026-04-25 13:27:18 -07:00
Chris Lu 559439bb9d docs(parquet-design): catalog-validated trust mode required in v1
Decision per review: pushdown ships with catalog-validated as the
default trust mode, not connector-trusted. M3 (catalog validation)
moves from optional/post-Phase-1 to a required gate for Phase 1
shipping, sequenced after M1 because the validator needs the parsed-
footer cache to verify RecordCount / column-chunk metadata against
the manifest. Connector-trusted is retained as an explicit dev-only
flag for local benchmarks but is rejected in production builds.

Update both the architectural-decision bullet and the M3 milestone:
move it before M2's external-ship gate, broaden the validation set
to include partition spec/values, and call out the snapshot-keyed
planning-result cache.
2026-04-25 13:26:47 -07:00
Chris Lu 6c98c9966e docs(parquet-design): residency is a standalone weed pushdown daemon
Decision per review: pushdown does not run inside the filer. Switch
the residency choice to a separate daemon process so its
availability, resource consumption, and scaling are decoupled from
the filer's hot path. The daemon is stateless on disk and reads data
+ side-index blobs from the filer over the existing gRPC API.

Update the architectural-decision bullet, add a daemon/ subpackage
to the code layout, and rewrite M0's wiring step from "register on
the filer's gRPC server" to "boot a standalone gRPC server in a new
weed pushdown subcommand".
2026-04-25 13:26:04 -07:00
Chris Lu d44bbbfbfc docs(parquet-design): co-locate side indexes in .index/ next to data
Reverse the earlier decision to place side indexes outside the table
prefix. Iceberg, Spark, and Hadoop ecosystems all treat
dot-prefixed paths as hidden by convention — table listings,
snapshot expiration, and orphan-file cleanup skip them — so a
.index/ directory next to the Parquet data is invisible to those
tools while keeping each index under the same parent folder as the
file it describes (so backup, replication, and lifecycle policies
apply uniformly without separate plumbing).

Two scopes:

- file-scoped: <parquet_parent>/.index/<file_name>/<identity>/...
- folder-scoped: <parquet_parent>/.index/<identity>/...

Document a fallback: deployments whose readers do not respect dot-
prefix conventions can either rename the prefix or relocate indexes
to a separate filer mount.
2026-04-25 13:25:21 -07:00
Chris Lu 06b4e46f4f docs(parquet-design): add dev plan with milestones and open questions
Companion to PARQUET_PUSHDOWN_DESIGN.md. Maps the design's six phases
to thirteen shippable milestones, with the architectural decisions
needed to start coding (residency, wire protocol, trust mode,
predicate engine scope, side-index path, library choices) called out
as defaults that the open-questions section asks to confirm.

No code changes — this commit only adds the plan document. M0 does
not start until the open questions are resolved.
2026-04-25 01:58:49 -07:00
Chris Lu 89ed588b34 docs(parquet-design): document trust model and catalog validation modes
The previous text said the server "treats this list as authoritative
and does not re-read the catalog" without flagging that this is only
safe when the caller is trusted. An untrusted client could:

- omit position/equality delete files to make deleted rows visible
- claim a snapshot id that does not match the data files listed
- point at data files outside the table's scope

Add a Trust Model section enumerating three deployment modes:
connector-trusted (v1 default; rely on a trust boundary around the
data plane), catalog-validated (server checks DataFiles + Deletes
against the manifest), and manifest-signed (future; requires catalog
support that doesn't exist today). Note the always-on guards (object
ACL, request-shape limits) and require the chosen mode to be
reported in PushdownStats so callers can verify which level handled
their request.
2026-04-25 01:56:07 -07:00
Chris Lu c9c487282b docs(parquet-design): include DVs in execution flow and Phase 4 rollout
The end-to-end delete flow listed only position-delete bitmaps and
equality-delete predicates, and Phase 4 only mentioned "position
delete masks" and "equality delete masks". Iceberg v3 deletion
vectors were covered in the dedicated subsection but never made it
into the operational flow or the rollout checklist, so a reader could
plausibly ship Phase 4 without DV support.

Add DV bitmap application as a distinct step (b) in the end-to-end
flow, and split Phase 4 into v2 position-delete ingestion, v3 DV
ingestion, equality-delete evaluation, partition matching, and
snapshot-aware identity. Phase 4 now lists the work that exists, not
a v2-only subset.
2026-04-25 01:55:34 -07:00
Chris Lu c3bfa48f25 docs(parquet-design): include partition matching in delete applicability
Iceberg delete files (position and equality) carry a partition spec
id and partition values, and apply only to data files in the same
partition. Both prose applicability rules (position deletes,
equality deletes) previously omitted this constraint, leaving them
correct only for unpartitioned tables.

- Add PartitionSpecId and PartitionValues to DataFileDescriptor and
  DeleteFileRef so the planner can pass the partition triple through.
- Rewrite both applicability rules to include partition equality
  alongside the sequence-number rule, formatted as set-builder
  predicates so each clause is explicit.
- Note that v3 deletion vectors get partition matching implicitly via
  ReferencedDataFile, but populating the fields anyway keeps the
  applicability check uniform.
2026-04-25 01:55:08 -07:00
Chris Lu b47bcd26c0 docs(parquet-design): use Puffin offset+length for DV identity, not a digest
The previous text claimed Puffin records a per-blob "content hash"
that could be used as the bitmap cache key. Puffin metadata exposes
offset, length, properties, optional compression info, and a CRC-32
per blob — not a manifest-level content hash. Treating the CRC as an
identity hash would also be wrong: it is for corruption detection,
not for cross-table cache lookup.

Replace BlobDigest []byte with BlobCRC32 uint32 (clearly scoped to
tamper / corruption checks), and use the immutable triple
(puffin_file_identity, blob_offset, blob_length) as the DV cache
identity. Update the prose and the cache-key shorthand to match.
2026-04-25 01:54:13 -07:00
Chris Lu 00f6ac592b docs(parquet-design): drop DeletionVector content value
Iceberg's manifest "content" enum has only two delete values:
POSITION_DELETES (1) and EQUALITY_DELETES (2). Iceberg v3 deletion
vectors are POSITION_DELETES stored in a Puffin file, not a third
content value. The previous DeleteContent enum invented a
DeletionVector = 3 that does not exist in the spec, which would have
forced a translation step on the wire and confused any reader cross-
checking against an Iceberg manifest.

Drop DeletionVector from DeleteContent. A DV is now expressed as
(Content == PositionDeletes, FileFormat == FileFormatPuffin), which
matches what an Iceberg manifest actually carries. Update the
DeleteFileRef field comments and the prose that listed the old
three-way discriminator.
2026-04-25 01:53:34 -07:00
Chris Lu 89833c07d3 docs(parquet-design): correct DV applicability rule to inclusive
Deletion vectors are position-based (they encode row positions, just
in Puffin form), so they follow the same data_sequence_number rule as
v2 position-delete files: data_file.seq <= delete_file.seq. The
previous comment grouped DVs with equality deletes (strict <), which
would silently drop deletes recorded in the same snapshot as the
data file they target.
2026-04-25 01:52:54 -07:00
Chris Lu 08889b0f7b docs(parquet-design): identify columns by Iceberg field ID
String column refs are fragile under Iceberg schema evolution: a
rename leaves indexes built under the old name unreachable, and a
drop-and-re-add of the same name silently aliases two different
columns onto the same index path.

Replace string Column / Columns []string fields with a ColumnRef
struct (FieldId int32 + Path string hint), used in
ParquetPushdownRequest.Columns, VectorQuery.Column, and
PageRef.Column. The server trusts FieldId when set and falls back to
Path only for non-Iceberg-managed Parquet files where field IDs are
absent. Side-index file names are also keyed by field ID
(bitmap.fid_7, vector.fid_42.ivf) so that rename and re-add stay
unambiguous on disk.
2026-04-25 01:46:47 -07:00
Chris Lu 706ad03d0c docs(parquet-design): use DataSequenceNumber for delete scoping
Iceberg manifest entries carry two sequence numbers:

- data_sequence_number: the sequence number when the data was
  logically added (semantic time);
- file_sequence_number: the sequence number of the manifest entry
  itself (commit time).

Delete scoping (which delete files apply to which data files) is
defined entirely in terms of data_sequence_number, so the request
struct should name the field DataSequenceNumber, not the ambiguous
SequenceNumber. Rename on both DataFileDescriptor and DeleteFileRef,
spell out the scope inequalities (strict for equality/DV, inclusive
for position), and clarify in prose that file_sequence_number is not
used for delete scoping.
2026-04-25 01:45:07 -07:00
Chris Lu 3475df1203 docs(parquet-design): expand DeleteFileRef for v2 + v3 metadata
The previous DeleteFileRef carried only Path, SizeBytes,
SequenceNumber. That is not enough to distinguish v2 position-delete
files from equality-delete files from v3 deletion vectors, to know
which file format to read, to evaluate equality predicates (which
field IDs?), or to locate a DV inside a Puffin file.

Add:

- Content discriminator (PositionDeletes / EqualityDeletes /
  DeletionVector) matching the Iceberg manifest "content" enum
- FileFormat (Parquet / Avro / ORC / Puffin)
- EqualityFieldIds for equality-delete predicate columns
- BlobOffset / BlobLength / BlobDigest for Puffin DV blobs
- ReferencedDataFile, when the delete targets a single data file

Also collapse DataFileDescriptor.PositionDeletes +
DataFileDescriptor.EqualityDeletes into a single Deletes list now
that DeleteFileRef.Content discriminates among the three forms,
including DVs which fit neither old slot.
2026-04-25 01:44:15 -07:00
Chris Lu c9871ce290 docs(parquet-design): scope position-delete bitmap cache key
The previous text said the merged position-delete bitmap was "cached
as a side index" without spelling out the cache key, leaving the
implication that data-file identity was sufficient. It is not: the
merged bitmap is a function of the *set* of position-delete files that
currently target the data file, and that set changes as snapshots add
new delete files or compaction removes them.

Add a "cache key" subsection requiring (data_file_identity,
sorted_set_of_input_delete_file_ids), with the v3 DV shorthand of
(data_file_identity, puffin_blob_digest), and explain why snapshot_id
alone is neither necessary nor sufficient as a key.
2026-04-25 01:43:11 -07:00
Chris Lu eeef173dd0 docs(parquet-design): cover Iceberg v3 deletion vectors
The previous "Handling Iceberg Deletes" section knew only about v2
position-delete files and equality-delete files. Iceberg v3 introduces
deletion vectors (DVs) — Puffin "deletion-vector-v1" blobs holding a
roaring bitmap of file-absolute row positions for one data file —
which are not optional for v3-spec tables.

Add a comparison table for the three delete forms and a DV subsection
covering: per-data-file scope, the (puffin_path, offset, length)
pointer, cache-by-blob-content, and the v2/v3 mixed case where DVs and
position-delete files can co-exist during migration. Forward-link to
the cache-key section that the next commit tightens.
2026-04-25 01:42:42 -07:00
Chris Lu 317ee9d10c docs(parquet-design): use planned scan size for pushdown threshold
The cost rule of thumb keyed off "total file size" of the table, which
is the wrong input — a huge table that Iceberg has narrowed to a
single 4 MiB file should not pay for a pushdown round-trip, while a
medium table that planning leaves with thousands of small surviving
files still benefits. Switch the threshold to planned scan bytes plus
surviving file count, both observable after Iceberg planning, and
update the configuration knob names accordingly.
2026-04-25 01:31:39 -07:00
Chris Lu 9c71020915 docs(parquet-design): pair scores with row refs and clarify row position
Two ambiguities in the response shape:

- Scores []float32 was a parallel array to RowIds, with order as the
  only correlation. Replace with []ScoredRowRef so each score is
  bound to its row ref and unscored scalar results don't need a
  sentinel.
- RowRef.RowId int64 left it unclear whether the value was
  file-absolute or row-group-local. Iceberg position deletes are
  file-absolute, so make RowRef.FilePosition file-absolute and treat
  the RowGroup field as a derived locality hint rather than identity.
2026-04-25 01:31:13 -07:00
Chris Lu 7110d326fa docs(parquet-design): expand page-level index section
The previous text described page pruning as per-column min/max plus
offsets, which omits the actual hard part: page boundaries do not
align across columns within a row group, so a predicate evaluated on
one column's pages must be translated to row ranges (via OffsetIndex
first_row_index) and then back to *each projected column's* pages.

Spell out:

- the index wraps Parquet's ColumnIndex and OffsetIndex;
- pruning is row-range-driven, not byte-range-driven;
- per-column page selection is required for projected columns;
- dictionary pages are not in OffsetIndex but must be fetched along
  with any surviving data page from the same column chunk;
- repeated/nested fields use row positions, not value positions, so
  the cross-column translation still holds;
- planner falls back to row-group pruning if ColumnIndex/OffsetIndex
  are absent.
2026-04-25 01:30:45 -07:00
Chris Lu 06bb1782e4 docs(parquet-design): unify side-index paths under system-prefix
The vector-index and position-delete-bitmap path examples still used
the old "/table/_seaweed_index/" form, contradicting the rule (added
in 605907f5) that side indexes live outside the table prefix. Update
both examples to use the system-prefix/table_uuid/.../identity layout.
2026-04-25 01:30:11 -07:00
Chris Lu 775095c50d docs(parquet-design): correct equality-delete sequence-number scope
The previous text said "new data files added later may need to be
filtered by the same delete predicate until compaction", which is the
opposite of Iceberg's rule: equality deletes apply only to data files
whose sequence number is strictly less than the delete file's
sequence number. Files added at or after that point are untouched.

Rewrite the scope rule, link it to the per-data-file SequenceNumber
that the request now carries, and key the optional materialized-bitmap
cache by snapshot_id as well.
2026-04-25 01:29:44 -07:00
Chris Lu 7a53654b93 docs(parquet-design): add per-file descriptors to pushdown request
The previous Files []string was insufficient for two reasons:

- the server cannot validate that a cached side index still matches the
  data file without identity fields (size, record count, ETag);
- correct equality-delete scoping needs the Iceberg sequence number per
  data file, plus the set of position/equality delete files the
  client's Iceberg planner has attached to it.

Replace Files with DataFiles []DataFileDescriptor and document that
the client owns Iceberg planning while the server treats the resolved
set as authoritative.
2026-04-25 01:29:21 -07:00
Chris Lu fbabfbd353 docs(parquet-design): add residency/cost/security sections and wording fixes
- Add Index Residency and Failure Model section: filer-managed vs
  colocated vs index-serving-tier options, and graceful-degradation
  behavior when an index is missing or stale.
- Add Cost Model section spelling out when connectors should skip the
  pushdown API (small tables, low selectivity, no predicate).
- Add Security section noting that zone maps, blooms, and inverted
  indexes leak information beyond the underlying object's ACL, and
  scoping v1 to inherit the file's ACL with row-level security
  explicitly out of scope.
- Wording fixes: clarify CONTAINS is engine-side translation of
  LIKE/MATCH/array_contains; clarify the embedding column is in
  Parquet but the vector index is a side file; flag local filtering
  on volume servers as new infrastructure introduced by this design.
2026-04-25 01:24:01 -07:00
Chris Lu 605907f522 docs(parquet-design): clarify Phase 1 wording and side-index placement
- Phase 1 "row group stats cache" was misleading — those stats already
  live in the Parquet footer. The Phase 1 win is a parsed-footer cache,
  not new index data.
- The previous "/table/_seaweed_index/" path nested side indexes under
  the table prefix, which Iceberg orphan-file removal and snapshot
  expiration would surface or attempt to clean. Move side indexes to a
  separate system bucket / filer mount keyed by file identity, with the
  in-table-prefix layout listed only as a fallback.
2026-04-25 01:22:31 -07:00
Chris Lu afeb82ab87 docs(parquet-design): add filtered-ANN strategy for hybrid pushdown
The hybrid scalar+vector flow assumed the vector index could accept
arbitrary scalar pre-filters, which most off-the-shelf IVF/HNSW
implementations do not. Document the realistic strategies (partitioned
IVF for v1, post-filter with overscan as fallback, filtered HNSW
deferred) and the planner heuristic for choosing between them.
2026-04-25 01:21:58 -07:00
Chris Lu e31cb80812 docs(parquet-design): make row-id response optional and bounded
A predicate matching millions of rows would force the server to return
millions of row refs. Default the response to range-oriented data
(file/row-group/page refs), require RequestRowIds opt-in for per-row
output, and cap returned ids via MaxRowIds with a Truncated flag.
2026-04-25 01:21:32 -07:00
Chris Lu 28c834c76f docs(parquet-design): specify predicate wire format and enum metrics
Replace opaque Predicate []byte with a tagged (PredicateKind, bytes)
pair, naming Substrait as the canonical v1 format and Iceberg
Expression JSON as a convenience. Replace the free-form Metric string
on VectorQuery with a VectorMetric enum.
2026-04-25 01:21:11 -07:00
Chris Lu 51a209cf60 docs(parquet-design): split position vs equality Iceberg deletes
Position deletes are file-scoped and precomputable as a per-data-file
bitmap. Equality deletes are predicate-scoped, can apply to many data
files including ones added later, and must be evaluated at query time.
Document both flows separately and remove the implication that
equality-delete state can be flattened into a static bitmap.
2026-04-25 01:20:53 -07:00
Chris Lu b114b318ae docs(parquet-design): tighten index identity
Drop modification time from the identity tuple — it is not stable across
replication and metadata-only operations and is unnecessary alongside
ETag or Iceberg manifest fields. Order remaining fields by strength,
with Iceberg manifest identity preferred when available.
2026-04-25 01:20:21 -07:00
Chris Lu 9ecd455310 docs(parquet-design): reframe internal mapping as logical-only
The previous wording suggested SeaweedFS would split a Parquet file
into per-column-chunk needles, which contradicts the design's
"do not modify the file" goal. Clarify that the per-column-chunk view
is a planning abstraction over byte ranges in the unchanged object.
2026-04-25 01:20:02 -07:00
Chris Lu ca4c8d977d docs: add SeaweedFS-aware Parquet pushdown design draft
Draft design note for keeping Parquet files compatible with existing
engines (Spark, Trino, DuckDB, PyArrow, Iceberg) while building
auxiliary side indexes and a pushdown API for SeaweedFS-aware clients.
Covers physical layout, side index types (footer cache, zone map,
page index, bloom, bitmap, B-tree, inverted, vector), pushdown API
sketch, Iceberg delete handling, and a phased rollout plan.
2026-04-25 01:15:42 -07:00
26 changed files with 5394 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
# Parquet Pushdown Dev Plan
Companion to [PARQUET_PUSHDOWN_DESIGN.md](./PARQUET_PUSHDOWN_DESIGN.md). The design defines *what* the system does; this doc defines *how it lands in this codebase, in what order, with what milestones*.
## Status
Open questions resolved (see [Decisions Recorded](#decisions-recorded)). M0 is unblocked.
## Goals for the implementation
1. Ship the design's Phase 1 in production-quality form: a pushdown service that, given an Iceberg snapshot resolution, returns column-chunk byte ranges and prunes row groups using parsed-footer statistics.
2. Establish the package layout, wire protocol, and trust-mode plumbing so later phases (scalar indexes, page-level pruning, deletes, vector search) attach without reshaping the surface.
3. Avoid breaking any existing read path — pushdown is additive; standard S3 reads keep working.
## What already exists in the codebase
| Component | Path | Reuse |
|---|---|---|
| Iceberg-Go client and table/manifest types | `weed/s3api/iceberg/`, `weed/plugin/worker/iceberg/` | Catalog reads, manifest parsing, planning |
| Parquet reader (`parquet-go/parquet-go v0.28.0`) | `weed/mq/logstore/`, `weed/query/engine/parquet_scanner.go` | Footer parse, ColumnIndex/OffsetIndex |
| SQL/value type conversion | `weed/query/engine/parquet_scanner.go`, `weed/query/sqltypes/` | Predicate constant typing |
| Iceberg compaction worker (uses iceberg-go for planning) | `weed/plugin/worker/iceberg/planning_index.go` | Reference for snapshot → file-list resolution |
| Filer gRPC + protobuf scaffolding | `weed/pb/filer_pb/`, `weed/server/filer_grpc_server.go` | Wire-protocol pattern, auth |
| S3 IAM / bucket ACL | `weed/s3api/`, `weed/iam/` | Trust enforcement on data files |
| Substrait Go SDK (already a transitive dep) | `github.com/substrait-io/substrait-go/v7` | Predicate decode in v2 |
The dependencies needed for v1 (`parquet-go`, `iceberg-go`, `RoaringBitmap/roaring`, `substrait-go/v7`) are already in `go.mod` (some as indirect; will become direct).
## Architectural decisions for v1
These are commitments unless an open question below changes them.
- **Residency: catalog server (planning) + volume servers (heavy execution).** Pushdown is *not* a separate daemon. Planning (snapshot resolution, file-level pruning, side-index registry, footer cache, request validation) runs inside the existing catalog-server role — today colocated with the S3 gateway's Iceberg REST handlers in `weed/s3api/iceberg/`. Heavy execution (local index lookup, scalar predicate evaluation against indexes, vector distance + local top-K) runs on the volume server next to the data. The S3 gateway remains the compatibility path for unmodified readers. Rationale: the catalog already knows tables, snapshots, and table-to-files mapping, which is exactly what the planner needs — adding a separate daemon would duplicate that state. Heavy work goes to the volume servers so a single hot table cannot bottleneck cluster-wide planning. A standalone query-worker tier remains an option (design's Phase 4) only if measurements demand it.
- **Wire protocol: gRPC.** New service definition `parquet_pushdown_pb.ParquetPushdown` in `weed/pb/parquet_pushdown_pb/`. REST/HTTP shim only if a connector requires it. JSON variant deferred.
- **Trust mode for v1: catalog-validated, with connector-trusted as a developer-only flag.** The default deployment validates every request's `DataFiles` and `Deletes` against the Iceberg catalog at the requested snapshot before serving (full rules in [Trust Model](./PARQUET_PUSHDOWN_DESIGN.md#trust-model-and-catalog-validation)). Connector-trusted is supported but only enabled by an explicit flag for local development and benchmarks; it is not the default and is rejected in production builds. Manifest-signed is not on the roadmap.
- **Predicate engine for v1: built-in subset evaluator.** The wire format accepts Substrait `ExtendedExpression`, but v1 implements only: comparisons (`=, !=, <, <=, >, >=`), `IN`, `BETWEEN`, `IS NULL`, and boolean `AND/OR/NOT` over them. Anything outside this subset returns `unsupported predicate`, and the connector falls back to a standard scan. Full Substrait evaluation lands in Phase 2.
- **Side-index physical location:** co-located with the data in a hidden `.index/` directory next to each Parquet file or its parent folder, governed by the same filer ACLs as the data. Path layout matches the design's `<parquet_parent>/.index/<file_name>/<identity>/...` (file-scoped) and `<parquet_parent>/.index/<identity>/...` (folder-scoped) shapes — see [Physical Layout](./PARQUET_PUSHDOWN_DESIGN.md#physical-layout). The hidden-prefix name is configurable for deployments whose readers do not respect dot-prefix conventions.
- **Parquet library: `parquet-go/parquet-go`** (already in use elsewhere in the repo). Apache `arrow-go/v18/parquet` is *not* introduced.
- **Bitmap library: `RoaringBitmap/roaring`.** Used for position-delete bitmaps and DV decoding.
## Code layout
Code lives in two trees that match the architecture's two tiers. The proto stays shared.
**Catalog-server side** — registered on the gRPC server hosted by the catalog (today colocated with the S3 gateway's Iceberg REST handlers in `weed/s3api/iceberg/`):
```text
weed/s3api/iceberg/pushdown/
service.go # SeaweedParquetPushdown service entry
request_validation.go # request-shape limits, trust-mode enforcement
catalog/ # Phase 1: catalog-validated mode
validator.go # cross-checks request DataFiles against manifest
footer/
cache.go # parsed-footer LRU, keyed by file identity
parser.go # footer + ColumnIndex/OffsetIndex extraction
predicate/
substrait_decode.go # decode Substrait ExtendedExpression
iceberg_decode.go # decode Iceberg Expression JSON
eval_subset.go # v1 built-in evaluator (comparisons + boolean)
rowgroup_prune.go # zone-map pruning for v1
registry/ # Phase 2: side-index registry
schema.go # registry rows + filer-backed CRUD
reconciler.go # background freshness checker
delete/ # Phase 2: Iceberg delete metadata
position.go # tracks v2 position-delete files + DVs
equality.go # tracks v2 equality-delete files
loader.go # FileHandle + Loader (filer-backed for production)
stats.go # PushdownStats accumulation
```
**Volume-server side** (lands in Phase 3) — registered on the volume server's gRPC surface so index lookup, predicate evaluation, and vector compute run colocated with data:
```text
weed/server/volume_grpc_pushdown.go # entry point on the volume server
weed/storage/pushdown/
index/
bloom/ # Phase 3
bitmap/ # Phase 3
btree/ # Phase 3
page/ # Phase 3
vector/ivf/ # Phase 3
exec/
predicate.go # evaluate v1 predicate subset against indexes
vector.go # local distance + top-K
bitmap_merge.go # delete-bitmap union / subtraction
```
**Shared proto** stays as today:
```text
weed/pb/parquet_pushdown.proto # request/response messages
weed/pb/parquet_pushdown_pb/ # generated
```
Tests live alongside the code. Cross-tier integration tests live in `test/parquet_pushdown/`.
> **Existing code at `weed/parquet_pushdown/`** (M0 + M1) was scaffolded under the prior standalone-daemon decision. A code-move commit relocates the package to `weed/s3api/iceberg/pushdown/`, drops the `weed pushdown` subcommand and the standalone-daemon bootstrap, and rewires the service into the existing s3api gRPC server. That move is sequenced as the first task of M2 work in the revised milestones below.
## Milestones
Milestones are organized by the design's four-phase rollout: catalog candidates, catalog index registry, volume execution, optional workers. M0 and M1 already shipped under the previous standalone-daemon decision; **M2-rebase** is the first task of the revised plan and unblocks everything that follows.
### Already shipped under the prior architecture
Two milestones live at `weed/parquet_pushdown/` and need the rebase below before further work:
- **M0 (shipped):** proto + service skeleton + bufconn integration test.
- **M1 (shipped):** parsed-footer cache + file/range-level pushdown for predicate-less requests.
### M2-rebase — Move pushdown into the catalog server (1 PR)
- Move `weed/parquet_pushdown/` to `weed/s3api/iceberg/pushdown/`. Update import paths.
- Drop `weed pushdown` and `weed pushdown.ping`; remove `weed/parquet_pushdown/daemon/`. The standalone-daemon path is retired.
- Register `SeaweedParquetPushdown` on the s3api gRPC server alongside the existing Iceberg REST handlers, gated by a `-pushdown.enabled` flag (default off until Phase 1 ships).
- Update the existing M0/M1 tests to exercise the service via the s3api gRPC registration rather than a bufconn-only listener.
- Acceptance: existing M0/M1 tests pass against the relocated package; `weed s3 -pushdown.enabled` exposes the service.
### Phase 1: Catalog returns candidate files / row groups / ranges
#### M3 — Row-group zone-map pruning (1 PR)
- `predicate/eval_subset.go`: evaluator for the v1 predicate subset.
- `predicate/rowgroup_prune.go`: read row-group statistics from the parsed footer, drop row groups where the predicate cannot be true, return per-data-file `RowGroupRef` list.
- `predicate/substrait_decode.go`: decode just enough Substrait to feed the subset evaluator; everything else returns `unsupported`.
- Acceptance:
- Predicate `WHERE timestamp BETWEEN t1 AND t2` against a 5M-row partitioned Iceberg table prunes to the expected row groups.
- `unsupported predicate` returns a status the connector can recognize and fall back from.
#### M4 — Catalog-validated trust mode (1 PR; required for Phase 1)
- `catalog/validator.go`: read the Iceberg snapshot via the existing internal Iceberg package (decision 6 below), verify `(Path, SizeBytes, RecordCount, DataSequenceNumber, PartitionSpecId, PartitionValues)` for each `DataFile` and that the `Deletes` list matches the manifest's attached delete files.
- Configurable per-endpoint: `pushdown.trust=catalog-validated|connector-trusted` (default `catalog-validated`; `connector-trusted` requires an explicit dev-mode flag).
- Cache validated planning result keyed by `(table, SnapshotId)` to amortize manifest reads across requests in the same snapshot.
- Acceptance: a tampered request (omitted delete file, wrong sequence number, file not in manifest, wrong partition values) is rejected with `permission_denied`. Cache hit/miss visible in `PushdownStats`.
**End of design Phase 1.** Phase 1 does not ship without M2-rebase + M3 + M4.
### Phase 2: Catalog tracks side-index registry
#### M5 — Index registry schema and CRUD (1 PR)
- `registry/schema.go`: registry rows of shape `(table, file_identity, kind, field_id) -> (.index path, build identity, build_time, healthy)`. Stored in the filer under a system path, separate from the table's bucket prefix.
- CRUD APIs (gRPC + internal Go) for register, lookup, mark-stale, delete.
- Planner consults the registry to know which indexes exist; missing entries fall through gracefully and are reported in stats.
- Acceptance: register, lookup, list, and delete round-trips pass; planner stats show `indexes_used` / `indexes_missing` correctly populated.
#### M6 — Iceberg delete metadata in the registry (1 PR)
- Register position-delete files, equality-delete files, and v3 deletion vectors as registry entries attached to the data file they target.
- Planner resolves applicable deletes per the design's applicability rules (sequence + partition + file scope, with per-row `file_path` filter for multi-target position-delete files).
- Acceptance: planner returns the same data-file → delete-files mapping as a brute-force Iceberg manifest scan.
#### M7 — Background reconciler (1 PR)
- Worker that iterates the registry, verifies each `.index/` payload exists at the recorded path with the recorded identity, marks stale rows, and rebuilds when possible.
- Compaction / snapshot expiration eviction: registry rows whose `file_identity` is no longer referenced by any active snapshot are reclaimable.
- Acceptance: tampering with an index payload causes the next request to report it missing; reconciler restores the registry on the next pass.
### Phase 3: Volume servers execute local index/vector pushdown
#### M8 — Volume-server bloom-filter index (1–2 PRs)
- Build path: a worker reads Parquet column chunks and writes SplitBlock Bloom Filters to `.index/<file_name>/<identity>/bloom.fid_<id>` next to the data needles. Registry entry written on completion.
- Read path: volume server gains a `PushdownExec` gRPC service that, given a (path, identity, kind, field_id, predicate), returns a per-row-group bitmap of surviving rows. Catalog plans for predicates touching bloom-able columns include the volume-server endpoint.
- Acceptance: equality predicate `WHERE user_id = 'x'` against a 10M-row column with no matches reads zero column-chunk bytes after planning.
#### M9 — Volume-server bitmap and B-tree (1 PR each)
- `index/bitmap/` for low/medium-cardinality columns; integrates with `RoaringBitmap`.
- `index/btree/` for sortable columns; range and point-lookup predicates.
- Acceptance: ≥10× row-group reduction vs M3's footer-only pruning on the standard test workload.
#### M10 — Volume-server page-level pruning (1 PR)
- `index/page/`: wraps Parquet's `ColumnIndex` + `OffsetIndex` per [Page-Level Index](./PARQUET_PUSHDOWN_DESIGN.md#3-page-level-index); row-range translation across columns; dictionary-page byte-range inclusion.
- Volume server returns `PageRef` entries with byte ranges for each projected column.
- Acceptance: predicate selectivity ≥0.99 returns ≤1 page per surviving row group across all projected columns.
#### M11 — Volume-server delete bitmap evaluation (1 PR)
- v2 position-delete bitmap merge per the design's filter-by-`file_path` rule; cached with the registry's identity key.
- v3 deletion-vector (Puffin `deletion-vector-v1`) reader + cache.
- v2 equality-delete per-pair bitmap evaluation, accelerated by the volume server's scalar indexes.
- Acceptance: a reference table with both delete forms returns identical row sets to a Spark scan of the same snapshot.
#### M12 — Volume-server vector index (1 PR)
- Partitioned IVF index per [Filtered-ANN strategy](./PARQUET_PUSHDOWN_DESIGN.md#filtered-ann-strategy); per-partition top-K runs entirely on the volume server. Catalog merges per-partition top-Ks across volume servers.
- Acceptance: recall ≥0.95 vs brute force at top-20, 10× end-to-end speedup on the standard test set.
#### M13 — Hybrid scalar+vector pushdown (1 PR)
- Planner selects partitioned-IVF, post-filter+overscan, or filtered-HNSW per the design's strategy section based on selectivity estimate.
- Acceptance: hybrid query that combines `tenant_id = 'x' AND timestamp >= t` with `ORDER BY embedding <-> q LIMIT 20` returns recall-correct results in <2× the cost of the lighter clause alone.
**End of design Phase 3.**
### Phase 4: Optional standalone query workers
Built only if profiling shows planning, hot-path coordination, or cross-volume merges become a bottleneck. Out of scope until measurements demand it.
### Connector integrations (parallel with Phases 1–3)
These run in parallel with Phases 1–3 once the catalog API stabilizes (post-M4). Each consumer reads byte ranges directly via S3 (compatibility path) or dispatches to volume-server endpoints (hot path) per request.
- DuckDB extension as PoC; consumes the gRPC API directly.
- Trino connector pushdown.
- Spark DataSource / Iceberg integration.
## Test strategy
- **Unit tests:** every package, with parquet-go in-memory fixtures.
- **Integration tests:** `test/parquet_pushdown/` — spins up filer + a small Iceberg catalog (reuse `weed/s3api/iceberg/` infrastructure), generates Parquet data with `parquet-go` writers, compares pushdown results to a brute-force scan.
- **Correctness oracle:** every milestone validates that pushdown output is a *strict subset* of a brute-force Iceberg+Parquet scan with the same predicate, and that the projected-column byte ranges decode to the same rows. The oracle is the safety net that lets us refactor index internals freely.
- **Performance suite:** runs in CI weekly, not on every PR. Tracks footer-cache hit rate, row-group reduction, page reduction, end-to-end query latency vs a baseline standard scan.
## Decisions Recorded
These were the open questions before M0. All have been resolved; recording for traceability and to anchor future amendments.
1. **Residency:** catalog server (planning, registry, footer cache) + volume servers (local index lookup, predicate eval, vector compute). Standalone daemons are *not* introduced; query workers as an extra tier remain a future option contingent on measurements. (See [Architectural decisions](#architectural-decisions-for-v1).)
2. **Service surface:** new `weed/pb/parquet_pushdown_pb/` package; not extending `filer_pb`.
3. **Trust mode:** catalog-validated is the default and required for Phase 1 shipping; connector-trusted is a dev-only flag rejected in production builds.
4. **Side-index path:** co-located in a hidden `.index/` directory next to the Parquet data, under the same parent folder. File-scoped: `<parquet_parent>/.index/<file_name>/<identity>/...`. Folder-scoped: `<parquet_parent>/.index/<identity>/...`. Hidden-prefix name configurable for non-dot-aware readers.
5. **Predicate subset for v1:** `=`, `!=`, `<`, `<=`, `>`, `>=`, `IN`, `BETWEEN`, `IS NULL`, boolean `AND/OR/NOT`. `LIKE` deferred to the inverted-index milestone. Wire format is Substrait `ExtendedExpression` from M0.
6. **Iceberg snapshot integration:** route through the existing internal Iceberg packages (`weed/s3api/iceberg/`, `weed/plugin/worker/iceberg/`); do not add a parallel `iceberg-go` integration in `weed/parquet_pushdown/`.
7. **Auth:** reuse the filer's existing auth. The request principal must have read access to every `DataFile` listed; the daemon enforces this by re-using the filer's authorization on every blob fetch.
8. **Test corpus:** generated from scratch in `test/parquet_pushdown/fixtures/`, parameterized over (with-position-deletes, with-equality-deletes, with-DVs, partitioned/unpartitioned).
9. **Phase 1 done bar:** passing CI tests + an I/O-reduction benchmark on a reference query. Connector PoC slips to M12.
10. **Performance regression budget for v1:** ≤10% added latency vs a direct ranged GET on a 10 MiB single-file scan in the worst case (predicate prunes nothing). The threshold is exposed as a configuration knob (`pushdown.max_overhead_pct`, default `10`) so deployments can tighten or loosen it without code changes.
## Risk register
- **Iceberg-Go maturity for v3 deletion vectors / Puffin.** If `iceberg-go v0.5.0` does not expose Puffin DV blobs, M8 needs a vendored Puffin parser. Spike during M0 to verify.
- **parquet-go ColumnIndex/OffsetIndex completeness.** M6 depends on full ColumnIndex/OffsetIndex support. If the library returns nil for some files, fall back to row-group-only pruning per the design.
- **Catalog read latency.** M3's per-request manifest read must be cacheable per snapshot, otherwise the catalog-validated mode adds >100ms per request. Cache eviction strategy is part of M3's spec.
- **Trust boundary leakage.** A pushdown response must not return data references that a direct read would deny. The auth check (open question 7) is load-bearing; integration tests in M0 must include negative cases.
+2
View File
@@ -37,6 +37,8 @@ var Commands = []*Command{
cmdMqAgent,
cmdMqBroker,
cmdMqKafkaGateway,
cmdPushdown,
cmdPushdownPing,
cmdS3,
cmdScaffold,
cmdServer,
+71
View File
@@ -0,0 +1,71 @@
package command
import (
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/parquet_pushdown"
"github.com/seaweedfs/seaweedfs/weed/parquet_pushdown/daemon"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/version"
)
var (
pushdownOptions PushdownOptions
)
type PushdownOptions struct {
filer *string
ip *string
port *int
trustMode *string
connectorTrustedAck *bool
}
func init() {
cmdPushdown.Run = runPushdown // break init cycle
pushdownOptions.filer = cmdPushdown.Flag.String("filer", "localhost:8888", "comma-separated filer server addresses for high availability")
pushdownOptions.ip = cmdPushdown.Flag.String("ip", util.DetectedHostAddress(), "pushdown daemon gRPC listen ip address")
pushdownOptions.port = cmdPushdown.Flag.Int("port", 18888, "pushdown daemon gRPC listen port")
pushdownOptions.trustMode = cmdPushdown.Flag.String("trust", string(parquet_pushdown.TrustModeCatalogValidated), "trust mode: catalog-validated (default) or connector-trusted (dev only)")
pushdownOptions.connectorTrustedAck = cmdPushdown.Flag.Bool("dev.connector_trusted_ack", false, "explicitly acknowledge running with -trust=connector-trusted; required for that mode")
}
var cmdPushdown = &Command{
UsageLine: "pushdown [-port=18888] [-filer=<ip:port>[,<ip:port>]...] [-trust=catalog-validated]",
Short: "<WIP> start the SeaweedFS Parquet pushdown daemon (M0 skeleton)",
Long: `start the SeaweedFS Parquet pushdown daemon.
The pushdown daemon is a standalone gRPC service that accelerates Parquet/
Iceberg scans by pruning files, row groups, and pages before the engine
reads data. See PARQUET_PUSHDOWN_DESIGN.md and PARQUET_PUSHDOWN_DEV_PLAN.md
at the repo root for the full design and milestone plan.
M0 ships request validation and the gRPC surface only; pruning logic
lands in M1+.
Trust modes:
catalog-validated (default)
Server validates each request's DataFiles and Deletes against the
Iceberg catalog at the requested snapshot before serving.
connector-trusted
Skip catalog validation. Developer-only; production builds must
not enable. Requires the explicit -dev.connector_trusted_ack flag.
Multiple filer addresses can be specified for high availability,
separated by commas.`,
}
func runPushdown(cmd *Command, args []string) bool {
cfg := daemon.Config{
IP: *pushdownOptions.ip,
Port: *pushdownOptions.port,
FilerAddresses: pb.ServerAddresses(*pushdownOptions.filer).ToAddresses(),
TrustMode: parquet_pushdown.TrustMode(*pushdownOptions.trustMode),
ConnectorTrustedAck: *pushdownOptions.connectorTrustedAck,
Version: version.Version(),
}
if err := daemon.Run(cfg); err != nil {
glog.Fatalf("pushdown daemon: %v", err)
}
return true
}
+71
View File
@@ -0,0 +1,71 @@
package command
import (
"context"
"fmt"
"os"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/seaweedfs/seaweedfs/weed/glog"
pb "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
)
var (
pushdownPingOptions PushdownPingOptions
)
type PushdownPingOptions struct {
target *string
timeout *time.Duration
useTLS *bool
}
func init() {
cmdPushdownPing.Run = runPushdownPing
pushdownPingOptions.target = cmdPushdownPing.Flag.String("server", "localhost:18888", "pushdown daemon gRPC address")
pushdownPingOptions.timeout = cmdPushdownPing.Flag.Duration("timeout", 5*time.Second, "RPC deadline")
pushdownPingOptions.useTLS = cmdPushdownPing.Flag.Bool("tls", false, "dial with TLS using grpc.client config")
}
var cmdPushdownPing = &Command{
UsageLine: "pushdown.ping [-server=<ip:port>]",
Short: "<WIP> ping a running pushdown daemon (M0 smoke test)",
Long: `ping a running pushdown daemon.
Calls the SeaweedParquetPushdown.Ping RPC and prints the daemon's
reported version and trust mode. Used as a deployment smoke test
and by the M0 integration test.`,
}
func runPushdownPing(cmd *Command, args []string) bool {
util.LoadSecurityConfiguration()
dialOpt := grpc.WithTransportCredentials(insecure.NewCredentials())
if *pushdownPingOptions.useTLS {
dialOpt = security.LoadClientTLS(util.GetViper(), "grpc.client")
}
ctx, cancel := context.WithTimeout(context.Background(), *pushdownPingOptions.timeout)
defer cancel()
conn, err := grpc.NewClient(*pushdownPingOptions.target, dialOpt)
if err != nil {
glog.Errorf("dial %s: %v", *pushdownPingOptions.target, err)
return false
}
defer conn.Close()
resp, err := pb.NewSeaweedParquetPushdownClient(conn).Ping(ctx, &pb.PingRequest{})
if err != nil {
glog.Errorf("ping %s: %v", *pushdownPingOptions.target, err)
return false
}
fmt.Fprintf(os.Stdout, "ok: server=%s version=%q trust=%q\n", *pushdownPingOptions.target, resp.Version, resp.TrustMode)
return true
}
+66
View File
@@ -0,0 +1,66 @@
// Package daemon hosts the standalone `weed pushdown` process.
// It owns gRPC server bootstrap, the filer client used by later
// milestones to read Parquet data and side-index blobs, and the
// process-level config plumbing.
package daemon
import (
"errors"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/parquet_pushdown"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
// Config controls how the daemon listens, talks to the filer, and
// validates incoming requests. Caller (the `weed pushdown` command)
// builds this from CLI flags.
type Config struct {
// IP is the bind address for the gRPC listener. Empty defaults to
// the auto-detected host address.
IP string
// Port is the gRPC listener port.
Port int
// FilerAddresses is the comma-resolved list of filer endpoints
// the daemon talks to for data reads and (in M3) catalog access.
// Required: at least one entry.
FilerAddresses []pb.ServerAddress
// TrustMode is the request-validation strictness. Default is
// "catalog-validated"; "connector-trusted" is a developer-only
// mode that the daemon refuses to accept in production builds
// (see ConnectorTrustedAck).
TrustMode parquet_pushdown.TrustMode
// ConnectorTrustedAck is the explicit acknowledgement required to
// run with TrustMode == TrustModeConnectorTrusted. The flag exists
// to make the dev-only mode awkward to enable by accident.
ConnectorTrustedAck bool
// Version is reported in PingResponse / PushdownStats. Caller
// usually fills this with weed/util/version.Version().
Version string
}
// Validate checks the config before the daemon starts.
func (c *Config) Validate() error {
if c.Port <= 0 || c.Port > 65535 {
return fmt.Errorf("port %d is out of range", c.Port)
}
if len(c.FilerAddresses) == 0 {
return errors.New("at least one filer address is required")
}
switch c.TrustMode {
case "", parquet_pushdown.TrustModeCatalogValidated:
c.TrustMode = parquet_pushdown.TrustModeCatalogValidated
case parquet_pushdown.TrustModeConnectorTrusted:
if !c.ConnectorTrustedAck {
return errors.New("connector-trusted is a developer-only trust mode; pass -dev.connector_trusted_ack to enable")
}
default:
return fmt.Errorf("unknown trust mode %q", c.TrustMode)
}
return nil
}
@@ -0,0 +1,76 @@
package daemon
import (
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/parquet_pushdown"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
func baseConfig() Config {
return Config{
IP: "127.0.0.1",
Port: 18888,
FilerAddresses: []pb.ServerAddress{"localhost:8888"},
Version: "test",
}
}
func TestConfig_ValidateOK(t *testing.T) {
c := baseConfig()
if err := c.Validate(); err != nil {
t.Fatalf("expected ok, got %v", err)
}
if c.TrustMode != parquet_pushdown.TrustModeCatalogValidated {
t.Errorf("default trust mode = %q, want %q", c.TrustMode, parquet_pushdown.TrustModeCatalogValidated)
}
}
func TestConfig_RejectsBadPort(t *testing.T) {
c := baseConfig()
c.Port = 0
if err := c.Validate(); err == nil {
t.Fatal("expected error for port=0")
}
c.Port = 70000
if err := c.Validate(); err == nil {
t.Fatal("expected error for port>65535")
}
}
func TestConfig_RequiresFiler(t *testing.T) {
c := baseConfig()
c.FilerAddresses = nil
if err := c.Validate(); err == nil {
t.Fatal("expected error when filer addresses empty")
}
}
// Connector-trusted is dev-only and must require an explicit
// acknowledgement flag. Operators must not be able to flip it on by
// passing -trust=connector-trusted alone.
func TestConfig_ConnectorTrustedRequiresAck(t *testing.T) {
c := baseConfig()
c.TrustMode = parquet_pushdown.TrustModeConnectorTrusted
err := c.Validate()
if err == nil {
t.Fatal("expected error when connector-trusted lacks ack")
}
if !strings.Contains(err.Error(), "connector_trusted_ack") {
t.Errorf("error %q should mention the ack flag", err.Error())
}
c.ConnectorTrustedAck = true
if err := c.Validate(); err != nil {
t.Fatalf("connector-trusted with ack should pass, got %v", err)
}
}
func TestConfig_RejectsUnknownTrustMode(t *testing.T) {
c := baseConfig()
c.TrustMode = parquet_pushdown.TrustMode("nonsense")
if err := c.Validate(); err == nil {
t.Fatal("expected error for unknown trust mode")
}
}
@@ -0,0 +1,48 @@
package daemon
import (
"context"
"fmt"
"time"
"google.golang.org/grpc"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// filerClient is a thin handle around the daemon's filer endpoints.
// M0 only uses it to confirm filer reachability at startup; later
// milestones add read paths for Parquet data and side-index blobs.
type filerClient struct {
addresses []pb.ServerAddress
grpcDialOption grpc.DialOption
}
func newFilerClient(addresses []pb.ServerAddress, grpcDialOption grpc.DialOption) *filerClient {
return &filerClient{addresses: addresses, grpcDialOption: grpcDialOption}
}
// waitUntilReachable blocks until the daemon can complete a
// GetFilerConfiguration RPC against any of its configured filer
// addresses, or the context is cancelled. Mirrors the iam command's
// startup-time filer probe.
func (c *filerClient) waitUntilReachable(ctx context.Context) error {
for {
err := pb.WithOneOfGrpcFilerClients(false, c.addresses, c.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
_, getErr := client.GetFilerConfiguration(ctx, &filer_pb.GetFilerConfigurationRequest{})
return getErr
})
if err == nil {
glog.V(0).Infof("pushdown daemon connected to filers %v", c.addresses)
return nil
}
glog.V(0).Infof("pushdown daemon waiting for filers %v: %v", c.addresses, err)
select {
case <-ctx.Done():
return fmt.Errorf("filer reachability wait cancelled: %w", ctx.Err())
case <-time.After(time.Second):
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package daemon
import (
"context"
"fmt"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/parquet_pushdown"
"github.com/seaweedfs/seaweedfs/weed/pb"
pbpushdown "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/grace"
)
// Run starts the pushdown daemon. It blocks until the gRPC server
// returns (graceful shutdown on SIGTERM, or error from Serve). Always
// validates the config first.
func Run(cfg Config) error {
if err := cfg.Validate(); err != nil {
return fmt.Errorf("pushdown config: %w", err)
}
util.LoadSecurityConfiguration()
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
// Probe the filer until reachable so we fail fast on bad config
// and emit a clear "waiting for filers" log otherwise. Bound the
// probe so the daemon doesn't sit forever if the operator hands
// in an unreachable filer.
probeCtx, probeCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer probeCancel()
fc := newFilerClient(cfg.FilerAddresses, grpcDialOption)
if err := fc.waitUntilReachable(probeCtx); err != nil {
return fmt.Errorf("filer probe failed: %w", err)
}
svc := parquet_pushdown.New(parquet_pushdown.Options{
Version: cfg.Version,
TrustMode: cfg.TrustMode,
// M1 uses LocalLoader; the filer-backed Loader lands when the
// catalog-validated path needs to fetch real data files (M2+).
Loader: parquet_pushdown.LocalLoader{},
})
grpcL, localL, err := util.NewIpAndLocalListeners(cfg.IP, cfg.Port, 0)
if err != nil {
return fmt.Errorf("listen on grpc port %d: %w", cfg.Port, err)
}
grpcS := pb.NewGrpcServer()
pbpushdown.RegisterSeaweedParquetPushdownServer(grpcS, svc)
reflection.Register(grpcS)
grace.OnInterrupt(grpcS.GracefulStop)
if localL != nil {
localGrpcS := pb.NewGrpcServer()
pbpushdown.RegisterSeaweedParquetPushdownServer(localGrpcS, svc)
reflection.Register(localGrpcS)
grace.OnInterrupt(localGrpcS.GracefulStop)
go serve(localGrpcS, localL, "pushdown localhost")
}
glog.V(0).Infof("pushdown daemon listening on %s:%d (trust=%s)", cfg.IP, cfg.Port, cfg.TrustMode)
return serve(grpcS, grpcL, "pushdown")
}
func serve(s *grpc.Server, l net.Listener, label string) error {
if err := s.Serve(l); err != nil && err != grpc.ErrServerStopped {
glog.Errorf("%s server serve error: %v", label, err)
return err
}
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package footer
import (
"errors"
"fmt"
"sync/atomic"
lru "github.com/hashicorp/golang-lru"
)
// Identity uniquely names a Parquet file's content per the design's
// Index Consistency rules. The cache key is the same identity used
// to invalidate side indexes when a file's content changes.
//
// Iceberg manifest fields are preferred when available (Path,
// SizeBytes, RecordCount + ETag) because they pin the file by both
// its bytes and its declared row count — defending against the
// pathological case where a write produces a file with the same
// length but different content. ETag fills in for raw-S3 access
// without an Iceberg manifest. Mtime is intentionally not part of
// identity (see PARQUET_PUSHDOWN_DESIGN.md "Index Consistency").
type Identity struct {
Path string
SizeBytes int64
RecordCount int64 // 0 when unknown (non-Iceberg-managed files)
ETag string
}
// Cache is a concurrent LRU of parsed footers keyed by Identity.
// Capacity is in entries, not bytes; Phase 1 footers are small
// enough that entry-count budgeting is easier to reason about.
type Cache struct {
c *lru.Cache
hits atomic.Int64
misses atomic.Int64
}
// NewCache returns a Cache holding up to size entries. Returns an
// error when size is non-positive (a zero-size LRU is too easy to
// misconfigure to silently disable caching).
func NewCache(size int) (*Cache, error) {
if size <= 0 {
return nil, fmt.Errorf("cache size must be positive, got %d", size)
}
c, err := lru.New(size)
if err != nil {
return nil, fmt.Errorf("new lru: %w", err)
}
return &Cache{c: c}, nil
}
// Get returns the cached ParsedFooter for id, or nil + false on miss.
func (c *Cache) Get(id Identity) (*ParsedFooter, bool) {
if v, ok := c.c.Get(id); ok {
c.hits.Add(1)
return v.(*ParsedFooter), true
}
c.misses.Add(1)
return nil, false
}
// Add stores pf under id. Existing entries are replaced.
func (c *Cache) Add(id Identity, pf *ParsedFooter) {
c.c.Add(id, pf)
}
// Stats returns cumulative hit and miss counters.
func (c *Cache) Stats() (hits, misses int64) {
return c.hits.Load(), c.misses.Load()
}
// Len returns the current entry count.
func (c *Cache) Len() int {
return c.c.Len()
}
// GetOrLoad atomically returns a cached entry or invokes load to
// produce one and inserts it. Two concurrent loads for the same id
// may both call load — the cache is not a singleflight; that
// optimization is deferred until profiling shows it matters. The
// returned pointer is the one stored in the cache so callers must
// treat it as read-only.
func (c *Cache) GetOrLoad(id Identity, load func() (*ParsedFooter, error)) (*ParsedFooter, error) {
if pf, ok := c.Get(id); ok {
return pf, nil
}
if load == nil {
return nil, errors.New("nil load func")
}
pf, err := load()
if err != nil {
return nil, err
}
if pf == nil {
return nil, errors.New("load returned nil ParsedFooter")
}
c.Add(id, pf)
return pf, nil
}
+142
View File
@@ -0,0 +1,142 @@
package footer
import (
"errors"
"sync"
"testing"
)
func TestCache_NewSizeRejectsZero(t *testing.T) {
if _, err := NewCache(0); err == nil {
t.Error("size=0 should be rejected")
}
if _, err := NewCache(-1); err == nil {
t.Error("negative size should be rejected")
}
}
func TestCache_HitsAndMissesTracked(t *testing.T) {
c, err := NewCache(8)
if err != nil {
t.Fatalf("new: %v", err)
}
id := Identity{Path: "a", SizeBytes: 1}
pf := &ParsedFooter{NumRows: 10}
if _, ok := c.Get(id); ok {
t.Fatal("first Get should miss")
}
c.Add(id, pf)
if got, ok := c.Get(id); !ok || got != pf {
t.Fatalf("expected cached pointer, got=%v ok=%v", got, ok)
}
hits, misses := c.Stats()
if hits != 1 || misses != 1 {
t.Errorf("hits=%d misses=%d, want 1,1", hits, misses)
}
}
// Two distinct identities must not alias. Same path, different size
// is still a different file from the cache's POV.
func TestCache_IdentityIsCompositeKey(t *testing.T) {
c, err := NewCache(8)
if err != nil {
t.Fatalf("new: %v", err)
}
a := Identity{Path: "x", SizeBytes: 100, RecordCount: 1}
b := Identity{Path: "x", SizeBytes: 200, RecordCount: 1}
c.Add(a, &ParsedFooter{NumRows: 1})
c.Add(b, &ParsedFooter{NumRows: 2})
gotA, _ := c.Get(a)
gotB, _ := c.Get(b)
if gotA == gotB {
t.Errorf("entries with different sizes must not alias")
}
if gotA.NumRows != 1 || gotB.NumRows != 2 {
t.Errorf("entries swapped: gotA=%d gotB=%d", gotA.NumRows, gotB.NumRows)
}
}
// Eviction is the LRU's job; the test just verifies the cache obeys
// its size cap and doesn't grow without bound.
func TestCache_EvictsAboveCap(t *testing.T) {
c, err := NewCache(2)
if err != nil {
t.Fatalf("new: %v", err)
}
c.Add(Identity{Path: "a", SizeBytes: 1}, &ParsedFooter{NumRows: 1})
c.Add(Identity{Path: "b", SizeBytes: 1}, &ParsedFooter{NumRows: 2})
c.Add(Identity{Path: "c", SizeBytes: 1}, &ParsedFooter{NumRows: 3})
if got := c.Len(); got != 2 {
t.Fatalf("Len = %d, want 2 after eviction", got)
}
}
func TestCache_GetOrLoadCachesAndDedupes(t *testing.T) {
c, err := NewCache(8)
if err != nil {
t.Fatalf("new: %v", err)
}
id := Identity{Path: "a", SizeBytes: 1}
calls := 0
load := func() (*ParsedFooter, error) {
calls++
return &ParsedFooter{NumRows: 42}, nil
}
first, err := c.GetOrLoad(id, load)
if err != nil {
t.Fatalf("first GetOrLoad: %v", err)
}
second, err := c.GetOrLoad(id, load)
if err != nil {
t.Fatalf("second GetOrLoad: %v", err)
}
if first != second {
t.Errorf("second GetOrLoad returned different pointer")
}
if calls != 1 {
t.Errorf("load called %d times, want 1", calls)
}
}
func TestCache_GetOrLoadPropagatesError(t *testing.T) {
c, err := NewCache(8)
if err != nil {
t.Fatalf("new: %v", err)
}
wantErr := errors.New("io boom")
_, err = c.GetOrLoad(Identity{Path: "a"}, func() (*ParsedFooter, error) { return nil, wantErr })
if !errors.Is(err, wantErr) {
t.Errorf("err = %v, want %v", err, wantErr)
}
if c.Len() != 0 {
t.Errorf("failed load should not insert; Len=%d", c.Len())
}
}
// Parallel reads on the same key should all observe a hit after the
// first writer wins, and the cache must not race-detect.
func TestCache_ConcurrentSafe(t *testing.T) {
c, err := NewCache(64)
if err != nil {
t.Fatalf("new: %v", err)
}
id := Identity{Path: "a", SizeBytes: 1}
c.Add(id, &ParsedFooter{NumRows: 1})
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for k := 0; k < 256; k++ {
_, _ = c.Get(id)
}
}()
}
wg.Wait()
}
+139
View File
@@ -0,0 +1,139 @@
// Package footer holds the parsed-footer cache and the parser that
// fills it. Phase 1 of the dev plan: parsed-footer cache + file/range-
// level pushdown. Page-level (ColumnIndex/OffsetIndex) consumption
// lands in M6; for M1 we capture the column-chunk byte ranges that
// file/range-level pushdown needs.
package footer
import (
"errors"
"fmt"
"io"
"github.com/parquet-go/parquet-go"
"github.com/parquet-go/parquet-go/format"
)
// ParsedFooter is the daemon's compact, cache-friendly view of a
// Parquet file's footer. It carries only the fields M1 actually
// consumes; later milestones extend it (ColumnIndex / OffsetIndex,
// statistics, bloom-filter offsets).
type ParsedFooter struct {
NumRows int64
NumCols int
NumGroups int
// ColumnPaths is the dotted path-in-schema for each leaf column,
// in the canonical order parquet-go assigns to the file.
ColumnPaths []string
// RowGroups[i] holds the byte ranges and row count for row group i.
RowGroups []RowGroupRanges
}
// RowGroupRanges enumerates the column chunks of one row group, in
// the canonical column order.
type RowGroupRanges struct {
NumRows int64
ColumnChunks []ColumnChunkRange
}
// ColumnChunkRange is the byte range to read for one column chunk in
// one row group, including the dictionary page region when present.
//
// Iceberg-style page-level pruning narrows reads further; M1 returns
// the full column-chunk byte range. Including the dictionary-page
// prefix when DictionaryPageOffset > 0 follows the rule from the
// design's Page-Level Index section: the dictionary page is not in
// OffsetIndex but must be fetched whenever any data page is read.
type ColumnChunkRange struct {
Path []string
Offset int64
Length int64
NumValues int64
TotalCompSize int64
}
// ParseFromReader reads only the footer of a Parquet file and
// returns the projection used by file/range-level pushdown.
//
// SkipPageIndex is set: M1 does not need ColumnIndex/OffsetIndex,
// and skipping them avoids two extra reads per parse for files that
// have page indexes written. M6 will turn this back on for the page-
// level pruning milestone.
func ParseFromReader(r io.ReaderAt, size int64) (*ParsedFooter, error) {
if r == nil {
return nil, errors.New("nil reader")
}
if size <= 0 {
return nil, fmt.Errorf("invalid size %d", size)
}
pf, err := parquet.OpenFile(r, size, parquet.SkipPageIndex(true), parquet.SkipBloomFilters(true))
if err != nil {
return nil, fmt.Errorf("open parquet footer: %w", err)
}
md := pf.Metadata()
out := &ParsedFooter{
NumRows: md.NumRows,
NumGroups: len(md.RowGroups),
RowGroups: make([]RowGroupRanges, len(md.RowGroups)),
}
// All row groups share the same leaf-column layout; sample row
// group 0 to get column paths and column count.
if len(md.RowGroups) > 0 {
out.NumCols = len(md.RowGroups[0].Columns)
out.ColumnPaths = make([]string, out.NumCols)
for j, c := range md.RowGroups[0].Columns {
out.ColumnPaths[j] = joinPath(c.MetaData.PathInSchema)
}
}
for i, rg := range md.RowGroups {
out.RowGroups[i].NumRows = rg.NumRows
out.RowGroups[i].ColumnChunks = make([]ColumnChunkRange, len(rg.Columns))
for j, c := range rg.Columns {
start, length := columnChunkBytes(&c)
out.RowGroups[i].ColumnChunks[j] = ColumnChunkRange{
Path: c.MetaData.PathInSchema,
Offset: start,
Length: length,
NumValues: c.MetaData.NumValues,
TotalCompSize: c.MetaData.TotalCompressedSize,
}
}
}
return out, nil
}
// columnChunkBytes returns the (offset, length) of the contiguous
// byte region that a reader needs to fetch to consume this column
// chunk: the dictionary page (when present) plus all data pages.
//
// parquet-go exposes DictionaryPageOffset as the start of the
// dictionary page when one exists, otherwise zero, and DataPageOffset
// as the start of the first data page. The end of the chunk is
// `start + TotalCompressedSize`.
func columnChunkBytes(c *format.ColumnChunk) (int64, int64) {
start := c.MetaData.DataPageOffset
if dict := c.MetaData.DictionaryPageOffset; dict > 0 && dict < start {
start = dict
}
return start, c.MetaData.TotalCompressedSize
}
func joinPath(parts []string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
}
out := parts[0]
for _, p := range parts[1:] {
out += "." + p
}
return out
}
+129
View File
@@ -0,0 +1,129 @@
package footer
import (
"bytes"
"testing"
"github.com/parquet-go/parquet-go"
)
type sampleRow struct {
ID int64
Name string
Value float64
}
// writeFixture builds a small Parquet buffer with two row groups so
// the parser exercises both row-group iteration and per-column-chunk
// byte ranges. RowGroupTargetSize is set tight enough that the writer
// flushes mid-input.
func writeFixture(t *testing.T, rows []sampleRow) []byte {
t.Helper()
schema := parquet.SchemaOf(sampleRow{})
var buf bytes.Buffer
w := parquet.NewGenericWriter[sampleRow](&buf, schema)
if _, err := w.Write(rows); err != nil {
t.Fatalf("write rows: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
return buf.Bytes()
}
func TestParseFromReader_ColumnChunksAndCounts(t *testing.T) {
rows := make([]sampleRow, 1000)
for i := range rows {
rows[i] = sampleRow{ID: int64(i), Name: "row", Value: float64(i)}
}
data := writeFixture(t, rows)
pf, err := ParseFromReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("parse: %v", err)
}
if pf.NumRows != int64(len(rows)) {
t.Errorf("NumRows = %d, want %d", pf.NumRows, len(rows))
}
if pf.NumCols != 3 {
t.Errorf("NumCols = %d, want 3", pf.NumCols)
}
wantCols := []string{"ID", "Name", "Value"}
if len(pf.ColumnPaths) != len(wantCols) {
t.Fatalf("ColumnPaths len %d, want %d", len(pf.ColumnPaths), len(wantCols))
}
for i, want := range wantCols {
if pf.ColumnPaths[i] != want {
t.Errorf("ColumnPaths[%d] = %q, want %q", i, pf.ColumnPaths[i], want)
}
}
if pf.NumGroups < 1 {
t.Fatalf("NumGroups = %d, want >= 1", pf.NumGroups)
}
// Every column chunk has a non-zero offset (data starts after the
// 4-byte magic header) and a positive length, and the byte ranges
// must not overlap within a single row group when sorted.
for gi, rg := range pf.RowGroups {
if rg.NumRows == 0 {
t.Errorf("row group %d has zero rows", gi)
}
if len(rg.ColumnChunks) != pf.NumCols {
t.Errorf("row group %d ColumnChunks len = %d, want %d", gi, len(rg.ColumnChunks), pf.NumCols)
}
for ci, cc := range rg.ColumnChunks {
if cc.Offset <= 0 {
t.Errorf("row group %d col %d offset = %d, want > 0", gi, ci, cc.Offset)
}
if cc.Length <= 0 {
t.Errorf("row group %d col %d length = %d, want > 0", gi, ci, cc.Length)
}
if cc.Offset+cc.Length > int64(len(data)) {
t.Errorf("row group %d col %d overruns file: end %d > size %d", gi, ci, cc.Offset+cc.Length, len(data))
}
}
}
}
func TestParseFromReader_RejectsInvalid(t *testing.T) {
if _, err := ParseFromReader(nil, 100); err == nil {
t.Error("nil reader should fail")
}
if _, err := ParseFromReader(bytes.NewReader([]byte("PAR1")), 0); err == nil {
t.Error("zero size should fail")
}
if _, err := ParseFromReader(bytes.NewReader([]byte("not parquet at all")), 18); err == nil {
t.Error("non-parquet bytes should fail")
}
}
// columnChunkBytes prefers the dictionary page offset when present
// because the dictionary page must be fetched alongside the data
// pages of the same chunk.
func TestColumnChunkBytes_DictionaryOffsetPreferred(t *testing.T) {
// Synthesize a fixture where dictionary encoding is likely:
// repeating low-cardinality strings nudge parquet-go to write a
// dictionary page. Then check that at least one column chunk
// reports a start offset before its data-page offset.
rows := make([]sampleRow, 2000)
for i := range rows {
rows[i] = sampleRow{ID: int64(i % 5), Name: "tenant-" + string(rune('a'+(i%5))), Value: float64(i % 5)}
}
data := writeFixture(t, rows)
pf, err := ParseFromReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("parse: %v", err)
}
// Sanity: at least one chunk's range should be plausible (length
// is positive and offset doesn't exceed the file). Beyond that we
// can't depend on a specific encoding choice from parquet-go.
for _, rg := range pf.RowGroups {
for _, cc := range rg.ColumnChunks {
if cc.Offset+cc.Length > int64(len(data)) {
t.Fatalf("chunk overruns file")
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package parquet_pushdown
import (
"context"
"fmt"
"io"
"os"
)
// FileHandle is what the service hands to the parser: a positional
// reader bound to a known size, with a Close so the caller can
// release any underlying resources.
type FileHandle interface {
io.ReaderAt
io.Closer
Size() int64
}
// Loader resolves a Parquet file's path to a FileHandle. The split
// between "local file" and "filer-backed" implementations lives here
// so the service stays oblivious to the storage backend, and so
// integration tests can plug in deterministic local files instead of
// spinning up a filer.
type Loader interface {
Open(ctx context.Context, path string) (FileHandle, error)
}
// LocalLoader treats `path` as a filesystem path. Used by integration
// tests and for local-development smoke runs against a Parquet file
// that is not under filer management.
type LocalLoader struct{}
func (LocalLoader) Open(_ context.Context, path string) (FileHandle, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
st, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("stat %q: %w", path, err)
}
if st.IsDir() {
_ = f.Close()
return nil, fmt.Errorf("%q is a directory", path)
}
return &localFile{f: f, size: st.Size()}, nil
}
type localFile struct {
f *os.File
size int64
}
func (l *localFile) ReadAt(p []byte, off int64) (int, error) { return l.f.ReadAt(p, off) }
func (l *localFile) Close() error { return l.f.Close() }
func (l *localFile) Size() int64 { return l.size }
+48
View File
@@ -0,0 +1,48 @@
package parquet_pushdown
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestLocalLoader_Open(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "a.bin")
want := []byte("hello pushdown")
if err := os.WriteFile(path, want, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
h, err := LocalLoader{}.Open(context.Background(), path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer h.Close()
if h.Size() != int64(len(want)) {
t.Errorf("Size = %d, want %d", h.Size(), len(want))
}
got := make([]byte, len(want))
n, err := h.ReadAt(got, 0)
if err != nil {
t.Fatalf("ReadAt: %v", err)
}
if n != len(want) || string(got) != string(want) {
t.Errorf("got %q (n=%d), want %q", got[:n], n, want)
}
}
func TestLocalLoader_RejectsMissing(t *testing.T) {
if _, err := (LocalLoader{}).Open(context.Background(), filepath.Join(t.TempDir(), "missing.bin")); err == nil {
t.Error("expected error opening missing file")
}
}
func TestLocalLoader_RejectsDirectory(t *testing.T) {
dir := t.TempDir()
if _, err := (LocalLoader{}).Open(context.Background(), dir); err == nil {
t.Error("expected error opening a directory")
}
}
+260
View File
@@ -0,0 +1,260 @@
package parquet_pushdown
// m1_round_trip_test.go is the M1 round-trip integration test from
// PARQUET_PUSHDOWN_DEV_PLAN.md:
//
// "Round-trip integration test against a 3-file Iceberg table built
// in test/parquet_pushdown/fixtures/.
//
// Footer cache hit-rate test (second call avoids re-parse; verified
// via PushdownStats.FooterCacheHits)."
//
// It generates three Parquet files in a temp dir using parquet-go,
// boots the SeaweedParquetPushdown service on a bufconn listener
// with LocalLoader pointing at the temp dir, and submits real RPCs.
// It does NOT require a filer; the filer-backed loader is M2+.
import (
"bytes"
"context"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/parquet-go/parquet-go"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
pb "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
)
type fixtureRow struct {
ID int64
Name string
Value float64
}
func writeParquetFile(t *testing.T, path string, rows []fixtureRow) {
t.Helper()
var buf bytes.Buffer
w := parquet.NewGenericWriter[fixtureRow](&buf, parquet.SchemaOf(fixtureRow{}))
if _, err := w.Write(rows); err != nil {
t.Fatalf("write rows: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
}
func makeFixtureTable(t *testing.T) (dir string, files []string, sizes []int64) {
t.Helper()
dir = t.TempDir()
for i, count := range []int{500, 750, 1000} {
name := filepath.Join(dir, "part-0000"+string(rune('1'+i))+".parquet")
rows := make([]fixtureRow, count)
for k := range rows {
rows[k] = fixtureRow{ID: int64(i*10000 + k), Name: "r", Value: float64(k)}
}
writeParquetFile(t, name, rows)
st, err := os.Stat(name)
if err != nil {
t.Fatalf("stat: %v", err)
}
files = append(files, name)
sizes = append(sizes, st.Size())
}
return dir, files, sizes
}
func startServiceForFixtures(t *testing.T) (pb.SeaweedParquetPushdownClient, func()) {
t.Helper()
lis := bufconn.Listen(1 << 20)
srv := grpc.NewServer()
svc := New(Options{
Version: "m1-test",
TrustMode: TrustModeCatalogValidated,
Loader: LocalLoader{},
CacheSize: 32,
})
pb.RegisterSeaweedParquetPushdownServer(srv, svc)
go func() { _ = srv.Serve(lis) }()
dialer := func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }
conn, err := grpc.NewClient(
"passthrough:///bufnet",
grpc.WithContextDialer(dialer),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
srv.Stop()
t.Fatalf("dial: %v", err)
}
return pb.NewSeaweedParquetPushdownClient(conn), func() {
_ = conn.Close()
srv.Stop()
}
}
func descriptorsFor(t *testing.T, files []string, sizes []int64) []*pb.DataFileDescriptor {
t.Helper()
out := make([]*pb.DataFileDescriptor, len(files))
for i, p := range files {
out[i] = &pb.DataFileDescriptor{Path: p, SizeBytes: sizes[i], RecordCount: 1}
}
return out
}
// Round-trip: three files, project two columns, no predicate.
// Expect 3 files × N row groups × 2 columns of FileRanges, each with
// a positive offset / length within the underlying file.
func TestPushdown_M1_RoundTrip(t *testing.T) {
_, files, sizes := makeFixtureTable(t)
client, cleanup := startServiceForFixtures(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.Pushdown(ctx, &pb.ParquetPushdownRequest{
Table: "db.t",
SnapshotId: 1,
DataFiles: descriptorsFor(t, files, sizes),
Columns: []*pb.ColumnRef{{Path: "ID"}, {Path: "Value"}},
})
if err != nil {
t.Fatalf("pushdown: %v", err)
}
if len(resp.FileRanges) == 0 {
t.Fatal("expected file ranges, got none")
}
// Per file, every range must reference that file and fit inside it.
byFile := map[string]int64{}
for i, p := range files {
byFile[p] = sizes[i]
}
for i, r := range resp.FileRanges {
size, ok := byFile[r.File]
if !ok {
t.Fatalf("range %d references unknown file %q", i, r.File)
}
if r.Offset <= 0 || r.Length <= 0 {
t.Errorf("range %d has invalid bounds (offset=%d length=%d)", i, r.Offset, r.Length)
}
if r.Offset+r.Length > size {
t.Errorf("range %d overruns file %q (offset=%d length=%d size=%d)", i, r.File, r.Offset, r.Length, size)
}
}
// First call: every footer is a miss; no hits.
if got := resp.Stats.FooterCacheHits; got != 0 {
t.Errorf("first call FooterCacheHits = %d, want 0", got)
}
if got := resp.Stats.FooterCacheMisses; got != int64(len(files)) {
t.Errorf("first call FooterCacheMisses = %d, want %d", got, len(files))
}
}
// Footer cache hit-rate: a second identical request must hit the
// cache for every file (zero misses).
func TestPushdown_M1_FooterCacheHits(t *testing.T) {
_, files, sizes := makeFixtureTable(t)
client, cleanup := startServiceForFixtures(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &pb.ParquetPushdownRequest{
Table: "db.t",
SnapshotId: 1,
DataFiles: descriptorsFor(t, files, sizes),
Columns: []*pb.ColumnRef{{Path: "ID"}},
}
if _, err := client.Pushdown(ctx, req); err != nil {
t.Fatalf("warm-up: %v", err)
}
resp, err := client.Pushdown(ctx, req)
if err != nil {
t.Fatalf("second call: %v", err)
}
if got := resp.Stats.FooterCacheHits; got != int64(len(files)) {
t.Errorf("FooterCacheHits = %d, want %d", got, len(files))
}
if got := resp.Stats.FooterCacheMisses; got != 0 {
t.Errorf("FooterCacheMisses = %d, want 0", got)
}
}
// Empty Columns list returns ranges for every column in every row group.
func TestPushdown_M1_NoColumnProjection(t *testing.T) {
_, files, sizes := makeFixtureTable(t)
client, cleanup := startServiceForFixtures(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.Pushdown(ctx, &pb.ParquetPushdownRequest{
Table: "db.t",
SnapshotId: 1,
DataFiles: descriptorsFor(t, files, sizes),
})
if err != nil {
t.Fatalf("pushdown: %v", err)
}
// Every fixture has 3 columns; the count must be a multiple of 3.
if len(resp.FileRanges)%3 != 0 {
t.Errorf("FileRanges count %d not a multiple of column count 3", len(resp.FileRanges))
}
}
// An unknown column path is rejected with InvalidArgument before any
// range is emitted.
func TestPushdown_M1_UnknownColumn(t *testing.T) {
_, files, sizes := makeFixtureTable(t)
client, cleanup := startServiceForFixtures(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := client.Pushdown(ctx, &pb.ParquetPushdownRequest{
Table: "db.t",
SnapshotId: 1,
DataFiles: descriptorsFor(t, files, sizes),
Columns: []*pb.ColumnRef{{Path: "definitely_not_a_real_column"}},
})
if err == nil {
t.Fatal("expected error, got nil")
}
}
// A field-id-only ColumnRef without a Path hint is unsupported in M1
// because schema-based resolution lands in M3.
func TestPushdown_M1_FieldIdOnlyRejected(t *testing.T) {
_, files, sizes := makeFixtureTable(t)
client, cleanup := startServiceForFixtures(t)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := client.Pushdown(ctx, &pb.ParquetPushdownRequest{
Table: "db.t",
SnapshotId: 1,
DataFiles: descriptorsFor(t, files, sizes),
Columns: []*pb.ColumnRef{{FieldId: 1}}, // no Path
})
if err == nil {
t.Fatal("expected error, got nil")
}
}
+152
View File
@@ -0,0 +1,152 @@
package parquet_pushdown
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
)
// Hard caps applied to every incoming request before any work is done.
// These guard against accidental or hostile blow-up; they sit well
// above any plausible legitimate request and are not user-tunable.
const (
maxDataFiles = 100_000
maxColumns = 10_000
maxPredicateBytes = 16 * 1024 * 1024 // matches gRPC default max message size
maxRowIdsCap = 10_000_000
maxVectorDim = 4096
maxTopK = 10_000
maxDeletesPerFile = 10_000
)
// validateRequest enforces request-shape limits before the server
// touches any data. Returns a gRPC status error with InvalidArgument
// when something is wrong, nil otherwise.
//
// This is intentionally schema-only validation — it does not consult
// the Iceberg catalog (that is M3 / catalog-validated mode) or read
// any files. Schema-only checks run in every trust mode.
func validateRequest(req *pb.ParquetPushdownRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is nil")
}
if req.Table == "" {
return status.Error(codes.InvalidArgument, "table is required")
}
if len(req.DataFiles) == 0 {
return status.Error(codes.InvalidArgument, "data_files must not be empty")
}
if len(req.DataFiles) > maxDataFiles {
return status.Errorf(codes.InvalidArgument, "data_files exceeds cap %d", maxDataFiles)
}
if len(req.Columns) > maxColumns {
return status.Errorf(codes.InvalidArgument, "columns exceeds cap %d", maxColumns)
}
if len(req.Predicate) > maxPredicateBytes {
return status.Errorf(codes.InvalidArgument, "predicate exceeds cap %d bytes", maxPredicateBytes)
}
if (req.PredicateKind != pb.PredicateKind_PREDICATE_KIND_UNSPECIFIED) != (len(req.Predicate) > 0) {
return status.Error(codes.InvalidArgument, "predicate_kind and predicate must be set together")
}
// A bound predicate is meaningless without naming the schema its
// field references resolve under, so require schema_id whenever a
// predicate is present. The deeper check (every field id is in
// the snapshot's schema) needs catalog access and runs in M3.
if len(req.Predicate) > 0 && req.SchemaId == 0 {
return status.Error(codes.InvalidArgument, "predicate requires schema_id so field references can be bound")
}
if req.MaxRowIds < 0 || req.MaxRowIds > maxRowIdsCap {
return status.Errorf(codes.InvalidArgument, "max_row_ids must be in [0, %d]", maxRowIdsCap)
}
if req.RequestRowIds && req.MaxRowIds == 0 {
return status.Error(codes.InvalidArgument, "request_row_ids requires a positive max_row_ids")
}
if req.Limit < 0 {
return status.Error(codes.InvalidArgument, "limit must be non-negative")
}
if err := validateVectorQuery(req.VectorQuery); err != nil {
return err
}
for i, df := range req.DataFiles {
if err := validateDataFile(df); err != nil {
return status.Errorf(codes.InvalidArgument, "data_files[%d]: %s", i, err.Error())
}
}
return nil
}
func validateVectorQuery(q *pb.VectorQuery) error {
if q == nil {
return nil
}
if q.Column == nil || (q.Column.FieldId == 0 && q.Column.Path == "") {
return status.Error(codes.InvalidArgument, "vector_query.column must identify a column")
}
if len(q.Vector) == 0 {
return status.Error(codes.InvalidArgument, "vector_query.vector must not be empty")
}
if len(q.Vector) > maxVectorDim {
return status.Errorf(codes.InvalidArgument, "vector_query.vector dim exceeds cap %d", maxVectorDim)
}
if q.TopK <= 0 || q.TopK > maxTopK {
return status.Errorf(codes.InvalidArgument, "vector_query.top_k must be in [1, %d]", maxTopK)
}
if q.Metric == pb.VectorMetric_VECTOR_METRIC_UNSPECIFIED {
return status.Error(codes.InvalidArgument, "vector_query.metric must be set")
}
return nil
}
func validateDataFile(df *pb.DataFileDescriptor) error {
if df == nil {
return status.Error(codes.InvalidArgument, "descriptor is nil")
}
if df.Path == "" {
return status.Error(codes.InvalidArgument, "path is required")
}
if df.SizeBytes < 0 {
return status.Error(codes.InvalidArgument, "size_bytes must be non-negative")
}
if df.RecordCount < 0 {
return status.Error(codes.InvalidArgument, "record_count must be non-negative")
}
if len(df.Deletes) > maxDeletesPerFile {
return status.Errorf(codes.InvalidArgument, "deletes exceeds cap %d", maxDeletesPerFile)
}
for i, del := range df.Deletes {
if err := validateDeleteFile(del); err != nil {
return status.Errorf(codes.InvalidArgument, "deletes[%d]: %s", i, err.Error())
}
}
return nil
}
func validateDeleteFile(del *pb.DeleteFileRef) error {
if del == nil {
return status.Error(codes.InvalidArgument, "delete is nil")
}
if del.Path == "" {
return status.Error(codes.InvalidArgument, "path is required")
}
switch del.Content {
case pb.DeleteContent_POSITION_DELETES:
if del.FileFormat == pb.FileFormat_FILE_FORMAT_PUFFIN {
// Deletion vector: blob bounds are mandatory and the DV
// must reference a single data file.
if del.BlobLength <= 0 {
return status.Error(codes.InvalidArgument, "deletion vector requires positive blob_length")
}
if del.ReferencedDataFile == "" {
return status.Error(codes.InvalidArgument, "deletion vector requires referenced_data_file")
}
}
case pb.DeleteContent_EQUALITY_DELETES:
if len(del.EqualityFieldIds) == 0 {
return status.Error(codes.InvalidArgument, "equality delete requires equality_field_ids")
}
default:
return status.Error(codes.InvalidArgument, "unsupported delete content type")
}
return nil
}
@@ -0,0 +1,33 @@
package parquet_pushdown
import (
"testing"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
)
// A predicate paired with a SchemaId is the well-formed shape; a
// well-formed predicate without SchemaId must be rejected (deeper
// catalog binding lands in M3).
func TestValidateRequest_PredicateRequiresSchemaId(t *testing.T) {
req := validRequest()
req.PredicateKind = pb.PredicateKind_PREDICATE_KIND_SUBSTRAIT
req.Predicate = []byte("bound expression bytes")
req.SchemaId = 7
if err := validateRequest(req); err != nil {
t.Fatalf("predicate + schema_id should pass, got %v", err)
}
req.SchemaId = 0
err := validateRequest(req)
if err == nil {
t.Fatal("predicate without schema_id should be rejected")
}
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("got %v, want InvalidArgument (err=%v)", status.Code(err), err)
}
}
@@ -0,0 +1,145 @@
package parquet_pushdown
import (
"testing"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
)
func validRequest() *pb.ParquetPushdownRequest {
return &pb.ParquetPushdownRequest{
Table: "db.t",
SnapshotId: 42,
DataFiles: []*pb.DataFileDescriptor{
{Path: "s3://bkt/db/t/data/part-00001.parquet", SizeBytes: 1024, RecordCount: 100, DataSequenceNumber: 1},
},
Columns: []*pb.ColumnRef{{FieldId: 1}},
}
}
func TestValidateRequest_OK(t *testing.T) {
if err := validateRequest(validRequest()); err != nil {
t.Fatalf("expected ok, got %v", err)
}
}
func TestValidateRequest_RejectsEmpty(t *testing.T) {
cases := []struct {
name string
mut func(*pb.ParquetPushdownRequest)
}{
{"nil request", func(r *pb.ParquetPushdownRequest) { *r = pb.ParquetPushdownRequest{} }},
{"missing table", func(r *pb.ParquetPushdownRequest) { r.Table = "" }},
{"empty data files", func(r *pb.ParquetPushdownRequest) { r.DataFiles = nil }},
{"data file missing path", func(r *pb.ParquetPushdownRequest) { r.DataFiles[0].Path = "" }},
{"negative size", func(r *pb.ParquetPushdownRequest) { r.DataFiles[0].SizeBytes = -1 }},
{"negative record count", func(r *pb.ParquetPushdownRequest) { r.DataFiles[0].RecordCount = -1 }},
{"negative limit", func(r *pb.ParquetPushdownRequest) { r.Limit = -1 }},
{"row ids without cap", func(r *pb.ParquetPushdownRequest) { r.RequestRowIds = true; r.MaxRowIds = 0 }},
{"max row ids over cap", func(r *pb.ParquetPushdownRequest) { r.MaxRowIds = maxRowIdsCap + 1 }},
{"predicate without kind", func(r *pb.ParquetPushdownRequest) { r.Predicate = []byte("x") }},
{"predicate kind without bytes", func(r *pb.ParquetPushdownRequest) {
r.PredicateKind = pb.PredicateKind_PREDICATE_KIND_SUBSTRAIT
}},
{"predicate without schema_id", func(r *pb.ParquetPushdownRequest) {
r.PredicateKind = pb.PredicateKind_PREDICATE_KIND_SUBSTRAIT
r.Predicate = []byte("bound expression")
r.SchemaId = 0
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := validRequest()
c.mut(req)
err := validateRequest(req)
if err == nil {
t.Fatal("expected error")
}
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got %v", status.Code(err))
}
})
}
}
func TestValidateRequest_VectorQuery(t *testing.T) {
cases := []struct {
name string
q *pb.VectorQuery
want codes.Code
}{
{"nil ok", nil, codes.OK},
{"missing column", &pb.VectorQuery{Vector: []float32{1}, TopK: 1, Metric: pb.VectorMetric_VECTOR_METRIC_L2}, codes.InvalidArgument},
{"empty vector", &pb.VectorQuery{Column: &pb.ColumnRef{FieldId: 1}, TopK: 1, Metric: pb.VectorMetric_VECTOR_METRIC_L2}, codes.InvalidArgument},
{"top_k zero", &pb.VectorQuery{Column: &pb.ColumnRef{FieldId: 1}, Vector: []float32{1}, Metric: pb.VectorMetric_VECTOR_METRIC_L2}, codes.InvalidArgument},
{"unspecified metric", &pb.VectorQuery{Column: &pb.ColumnRef{FieldId: 1}, Vector: []float32{1}, TopK: 1}, codes.InvalidArgument},
{"ok", &pb.VectorQuery{Column: &pb.ColumnRef{FieldId: 1}, Vector: []float32{1}, TopK: 1, Metric: pb.VectorMetric_VECTOR_METRIC_L2}, codes.OK},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := validRequest()
req.VectorQuery = c.q
err := validateRequest(req)
if status.Code(err) != c.want {
t.Fatalf("got %v, want %v (err=%v)", status.Code(err), c.want, err)
}
})
}
}
func TestValidateRequest_DeleteFiles(t *testing.T) {
cases := []struct {
name string
del *pb.DeleteFileRef
want codes.Code
}{
{
"position-delete file ok",
&pb.DeleteFileRef{Path: "p", Content: pb.DeleteContent_POSITION_DELETES, FileFormat: pb.FileFormat_FILE_FORMAT_PARQUET},
codes.OK,
},
{
"deletion vector ok",
&pb.DeleteFileRef{Path: "p", Content: pb.DeleteContent_POSITION_DELETES, FileFormat: pb.FileFormat_FILE_FORMAT_PUFFIN, BlobLength: 16, ReferencedDataFile: "data.parquet"},
codes.OK,
},
{
"deletion vector missing blob length",
&pb.DeleteFileRef{Path: "p", Content: pb.DeleteContent_POSITION_DELETES, FileFormat: pb.FileFormat_FILE_FORMAT_PUFFIN, ReferencedDataFile: "data.parquet"},
codes.InvalidArgument,
},
{
"deletion vector missing referenced data file",
&pb.DeleteFileRef{Path: "p", Content: pb.DeleteContent_POSITION_DELETES, FileFormat: pb.FileFormat_FILE_FORMAT_PUFFIN, BlobLength: 16},
codes.InvalidArgument,
},
{
"equality delete missing field ids",
&pb.DeleteFileRef{Path: "p", Content: pb.DeleteContent_EQUALITY_DELETES, FileFormat: pb.FileFormat_FILE_FORMAT_PARQUET},
codes.InvalidArgument,
},
{
"equality delete ok",
&pb.DeleteFileRef{Path: "p", Content: pb.DeleteContent_EQUALITY_DELETES, FileFormat: pb.FileFormat_FILE_FORMAT_PARQUET, EqualityFieldIds: []int32{1}},
codes.OK,
},
{
"unspecified content rejected",
&pb.DeleteFileRef{Path: "p"},
codes.InvalidArgument,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := validRequest()
req.DataFiles[0].Deletes = []*pb.DeleteFileRef{c.del}
err := validateRequest(req)
if status.Code(err) != c.want {
t.Fatalf("got %v, want %v (err=%v)", status.Code(err), c.want, err)
}
})
}
}
+249
View File
@@ -0,0 +1,249 @@
// Package parquet_pushdown implements the SeaweedFS-aware Parquet
// pushdown service. See PARQUET_PUSHDOWN_DESIGN.md and
// PARQUET_PUSHDOWN_DEV_PLAN.md at the repo root for the surrounding
// design and the milestone plan.
//
// M1 wires up the parsed-footer cache and file/range-level pushdown:
// for predicate-less, no-vector requests the service returns the
// column-chunk byte ranges of the projected columns. Predicate /
// vector evaluation lands in later milestones.
package parquet_pushdown
import (
"context"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/weed/parquet_pushdown/footer"
pb "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
)
// TrustMode controls how the server validates the request's
// DataFiles and Deletes against the Iceberg catalog. See the design's
// "Trust Model and Catalog Validation" section.
type TrustMode string
const (
// TrustModeCatalogValidated is the default. The server reads the
// Iceberg snapshot and verifies every DataFileDescriptor and
// DeleteFileRef against the manifest before serving.
TrustModeCatalogValidated TrustMode = "catalog-validated"
// TrustModeConnectorTrusted is a developer-only mode that skips
// catalog validation. Production builds must reject this mode at
// configuration time.
TrustModeConnectorTrusted TrustMode = "connector-trusted"
)
// defaultFooterCacheSize is the parsed-footer LRU's default capacity
// when Options.Cache is unset. Footer entries are small (a few KiB
// typically; the heaviest field is the per-row-group ColumnChunks
// slice that scales with column count and row group count). 4096
// covers a few thousand active Parquet files comfortably; can be
// overridden via Options.CacheSize.
const defaultFooterCacheSize = 4096
// Options configures a Service. Loader and Cache are pluggable so
// integration tests can wire deterministic local files; the daemon
// supplies a filer-backed Loader and a sized Cache.
type Options struct {
// Version is reported in PingResponse and stamped on PushdownStats.
Version string
// TrustMode determines request-validation strictness. The actual
// validation against the catalog lands in M3.
TrustMode TrustMode
// Loader resolves DataFile paths to FileHandles. Defaults to
// LocalLoader{} (filesystem-backed).
Loader Loader
// Cache is the parsed-footer LRU. When nil, a cache of CacheSize
// (or defaultFooterCacheSize) entries is created.
Cache *footer.Cache
// CacheSize is the entry cap when Cache is nil.
CacheSize int
}
// Service implements parquet_pushdown_pb.SeaweedParquetPushdownServer.
// One Service instance per daemon process.
type Service struct {
pb.UnimplementedSeaweedParquetPushdownServer
version string
trustMode TrustMode
loader Loader
cache *footer.Cache
}
// New constructs a Service from Options, applying defaults. Returns
// an error if the cache cannot be constructed (e.g. nonsense size).
func New(opts Options) *Service {
mode := opts.TrustMode
if mode == "" {
mode = TrustModeCatalogValidated
}
loader := opts.Loader
if loader == nil {
loader = LocalLoader{}
}
cache := opts.Cache
if cache == nil {
size := opts.CacheSize
if size <= 0 {
size = defaultFooterCacheSize
}
c, err := footer.NewCache(size)
if err != nil {
// Size is validated above to be > 0; this is a
// programmer-error path the cache implementation should
// never hit. Promoting to panic keeps New non-error and
// callers oblivious to a configuration knob they did not
// touch.
panic(fmt.Errorf("parquet_pushdown: build footer cache: %w", err))
}
cache = c
}
return &Service{
version: opts.Version,
trustMode: mode,
loader: loader,
cache: cache,
}
}
// Ping returns daemon liveness information. Used by smoke tests and
// connector health checks. Cheap; never reads files.
func (s *Service) Ping(_ context.Context, _ *pb.PingRequest) (*pb.PingResponse, error) {
return &pb.PingResponse{
Version: s.version,
TrustMode: string(s.trustMode),
}, nil
}
// Pushdown evaluates a request and returns surviving file/row-group/
// page byte ranges.
//
// M1 handles only the predicate-less, no-vector path: parse each
// data file's footer (cached by Identity) and return one FileRange
// per (data file, row group, projected column) triple covering that
// column chunk's byte range. Predicate / vector clauses fall through
// to Unimplemented until later milestones.
func (s *Service) Pushdown(ctx context.Context, req *pb.ParquetPushdownRequest) (*pb.ParquetPushdownResponse, error) {
stats := newStats(s.trustMode)
if err := validateRequest(req); err != nil {
return nil, err
}
if len(req.Predicate) > 0 || req.VectorQuery != nil {
st := status.New(codes.Unimplemented, "predicate / vector evaluation lands in a later milestone; M1 only handles file/range-level pushdown")
if d, err := st.WithDetails(stats.toProto()); err == nil {
return nil, d.Err()
}
return nil, st.Err()
}
resp := &pb.ParquetPushdownResponse{}
for _, df := range req.DataFiles {
pf, err := s.loadFooter(ctx, df, stats)
if err != nil {
return nil, status.Errorf(codes.Internal, "load footer for %q: %v", df.Path, err)
}
ranges, err := projectColumnRanges(df.Path, pf, req.Columns)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "project columns for %q: %v", df.Path, err)
}
resp.FileRanges = append(resp.FileRanges, ranges...)
}
resp.Stats = stats.toProto()
return resp, nil
}
func (s *Service) loadFooter(ctx context.Context, df *pb.DataFileDescriptor, stats *statsRecorder) (*footer.ParsedFooter, error) {
id := footer.Identity{
Path: df.Path,
SizeBytes: df.SizeBytes,
RecordCount: df.RecordCount,
ETag: df.Etag,
}
if pf, ok := s.cache.Get(id); ok {
stats.recordFooterCacheHit()
return pf, nil
}
stats.recordFooterCacheMiss()
h, err := s.loader.Open(ctx, df.Path)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
defer h.Close()
pf, err := footer.ParseFromReader(h, h.Size())
if err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
s.cache.Add(id, pf)
return pf, nil
}
// projectColumnRanges expands the requested column projection into
// per-row-group byte ranges over the data file. An empty Columns
// list means "every column"; when columns are listed they are
// matched by ColumnRef.Path against the file's leaf-column paths.
//
// Field-id-only projection (Path == "") needs the catalog schema to
// resolve to a leaf path and is therefore unsupported until M3
// (catalog-validated mode) plumbs the schema through.
func projectColumnRanges(filePath string, pf *footer.ParsedFooter, cols []*pb.ColumnRef) ([]*pb.FileRange, error) {
pathIdx := make(map[string]int, len(pf.ColumnPaths))
for i, p := range pf.ColumnPaths {
pathIdx[p] = i
}
indices, err := resolveColumnIndices(pathIdx, pf.NumCols, cols)
if err != nil {
return nil, err
}
out := make([]*pb.FileRange, 0, len(pf.RowGroups)*len(indices))
for _, rg := range pf.RowGroups {
for _, idx := range indices {
cc := rg.ColumnChunks[idx]
out = append(out, &pb.FileRange{
File: filePath,
Offset: cc.Offset,
Length: cc.Length,
})
}
}
return out, nil
}
func resolveColumnIndices(pathIdx map[string]int, numCols int, cols []*pb.ColumnRef) ([]int, error) {
if len(cols) == 0 {
out := make([]int, numCols)
for i := range out {
out[i] = i
}
return out, nil
}
out := make([]int, 0, len(cols))
for _, c := range cols {
if c == nil {
return nil, fmt.Errorf("nil column ref")
}
if c.Path == "" {
return nil, fmt.Errorf("column ref field_id=%d has no path hint; field-id-only resolution requires catalog schema (M3)", c.FieldId)
}
idx, ok := pathIdx[c.Path]
if !ok {
return nil, fmt.Errorf("column %q not in file", c.Path)
}
out = append(out, idx)
}
return out, nil
}
+175
View File
@@ -0,0 +1,175 @@
package parquet_pushdown
// service_grpc_test.go is the M0 integration test. It boots the
// Service on an in-process gRPC bufconn listener (no filer, no
// network) and exercises Ping and Pushdown end-to-end over real gRPC.
//
// The acceptance criterion from PARQUET_PUSHDOWN_DEV_PLAN.md is
// "calls Pushdown over gRPC, gets the expected Unimplemented error
// path with stats populated"; this file proves both, plus that the
// Pushdown Unimplemented status carries a PushdownStats detail with
// trust_mode and a non-zero server_time_micros.
import (
"context"
"net"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
pb "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
)
const bufSize = 1 << 20
func startTestServer(t *testing.T, opts Options) (pb.SeaweedParquetPushdownClient, func()) {
t.Helper()
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer()
pb.RegisterSeaweedParquetPushdownServer(srv, New(opts))
go func() {
if err := srv.Serve(lis); err != nil {
t.Logf("test server stopped: %v", err)
}
}()
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}
conn, err := grpc.NewClient(
"passthrough:///bufnet",
grpc.WithContextDialer(dialer),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
srv.Stop()
t.Fatalf("dial bufconn: %v", err)
}
cleanup := func() {
_ = conn.Close()
srv.Stop()
}
return pb.NewSeaweedParquetPushdownClient(conn), cleanup
}
func TestPing_ReportsConfiguredVersionAndTrustMode(t *testing.T) {
client, cleanup := startTestServer(t, Options{
Version: "test-1.2.3",
TrustMode: TrustModeCatalogValidated,
})
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := client.Ping(ctx, &pb.PingRequest{})
if err != nil {
t.Fatalf("ping failed: %v", err)
}
if resp.Version != "test-1.2.3" {
t.Errorf("version = %q, want %q", resp.Version, "test-1.2.3")
}
if resp.TrustMode != string(TrustModeCatalogValidated) {
t.Errorf("trust_mode = %q, want %q", resp.TrustMode, TrustModeCatalogValidated)
}
}
func TestPing_DefaultsTrustModeToCatalogValidated(t *testing.T) {
client, cleanup := startTestServer(t, Options{Version: "test"})
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := client.Ping(ctx, &pb.PingRequest{})
if err != nil {
t.Fatalf("ping failed: %v", err)
}
if resp.TrustMode != string(TrustModeCatalogValidated) {
t.Errorf("trust_mode default = %q, want %q", resp.TrustMode, TrustModeCatalogValidated)
}
}
// As of M1 the Unimplemented path is reserved for predicate /
// vector requests (file/range-level pushdown is implemented). Use a
// predicate-bearing request to exercise the Unimplemented + stats
// detail wire format.
func TestPushdown_ReturnsUnimplementedWithStats(t *testing.T) {
client, cleanup := startTestServer(t, Options{
Version: "test",
TrustMode: TrustModeCatalogValidated,
})
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req := &pb.ParquetPushdownRequest{
Table: "db.t",
SnapshotId: 1,
DataFiles: []*pb.DataFileDescriptor{
{Path: "s3://b/p.parquet", SizeBytes: 1, RecordCount: 1},
},
Columns: []*pb.ColumnRef{{FieldId: 1, Path: "id"}},
PredicateKind: pb.PredicateKind_PREDICATE_KIND_SUBSTRAIT,
Predicate: []byte("bound"),
SchemaId: 1,
}
_, err := client.Pushdown(ctx, req)
if err == nil {
t.Fatal("expected error, got nil")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("error is not a gRPC status: %v", err)
}
if st.Code() != codes.Unimplemented {
t.Fatalf("got code %v, want Unimplemented", st.Code())
}
// Acceptance: stats are attached as a detail on the Unimplemented
// status, with the daemon's trust mode and a non-zero elapsed.
var stats *pb.PushdownStats
for _, d := range st.Details() {
if s, ok := d.(*pb.PushdownStats); ok {
stats = s
break
}
}
if stats == nil {
t.Fatal("expected PushdownStats detail on Unimplemented status")
}
if stats.TrustMode != string(TrustModeCatalogValidated) {
t.Errorf("stats.trust_mode = %q, want %q", stats.TrustMode, TrustModeCatalogValidated)
}
if stats.ServerTimeMicros < 0 {
t.Errorf("stats.server_time_micros = %d, want >= 0", stats.ServerTimeMicros)
}
}
func TestPushdown_RejectsInvalidRequestBeforeUnimplemented(t *testing.T) {
client, cleanup := startTestServer(t, Options{Version: "test"})
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Empty data_files: validation should fire and produce
// InvalidArgument, not Unimplemented.
_, err := client.Pushdown(ctx, &pb.ParquetPushdownRequest{Table: "db.t"})
if err == nil {
t.Fatal("expected error, got nil")
}
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("got %v, want InvalidArgument (err=%v)", status.Code(err), err)
}
}
+45
View File
@@ -0,0 +1,45 @@
package parquet_pushdown
import (
"time"
pb "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb"
)
// statsRecorder accumulates per-request stats and converts to the
// wire form when the handler returns. Counts here are scoped to a
// single Pushdown call, not process-wide.
type statsRecorder struct {
start time.Time
trustMode TrustMode
footerCacheHits int64
footerCacheMisses int64
indexesUsed []string
indexesMissing []string
}
func newStats(trustMode TrustMode) *statsRecorder {
return &statsRecorder{start: time.Now(), trustMode: trustMode}
}
func (r *statsRecorder) recordFooterCacheHit() { r.footerCacheHits++ }
func (r *statsRecorder) recordFooterCacheMiss() { r.footerCacheMisses++ }
func (r *statsRecorder) markIndexUsed(kind string) {
r.indexesUsed = append(r.indexesUsed, kind)
}
func (r *statsRecorder) markIndexMissing(kind string) {
r.indexesMissing = append(r.indexesMissing, kind)
}
func (r *statsRecorder) toProto() *pb.PushdownStats {
return &pb.PushdownStats{
TrustMode: string(r.trustMode),
ServerTimeMicros: time.Since(r.start).Microseconds(),
FooterCacheHits: r.footerCacheHits,
FooterCacheMisses: r.footerCacheMisses,
IndexesUsed: r.indexesUsed,
IndexesMissing: r.indexesMissing,
}
}
+2
View File
@@ -18,6 +18,8 @@ gen:
protoc worker.proto --go_out=./worker_pb --go-grpc_out=./worker_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
mkdir -p ./plugin_pb
protoc plugin.proto --go_out=./plugin_pb --go-grpc_out=./plugin_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
mkdir -p ./parquet_pushdown_pb
protoc parquet_pushdown.proto --go_out=./parquet_pushdown_pb --go-grpc_out=./parquet_pushdown_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
# protoc filer.proto --java_out=../../other/java/client/src/main/java
cp filer.proto ../../other/java/client/src/main/proto
cp volume_server.proto master.proto remote.proto ../../seaweed-volume/proto/
+251
View File
@@ -0,0 +1,251 @@
syntax = "proto3";
package parquet_pushdown_pb;
option go_package = "github.com/seaweedfs/seaweedfs/weed/pb/parquet_pushdown_pb";
// SeaweedParquetPushdown is the gRPC surface of the standalone
// `weed pushdown` daemon. See PARQUET_PUSHDOWN_DESIGN.md and
// PARQUET_PUSHDOWN_DEV_PLAN.md for the surrounding design.
service SeaweedParquetPushdown {
// Ping returns daemon liveness information. Cheap; intended for
// smoke tests and connector health checks.
rpc Ping (PingRequest) returns (PingResponse);
// Pushdown takes a planner-resolved set of Iceberg data files and
// returns the byte ranges, row groups, pages, and (optionally) row
// refs that satisfy the request's predicate and vector clauses.
// M0 returns Unimplemented; later milestones fill in pruning logic.
rpc Pushdown (ParquetPushdownRequest) returns (ParquetPushdownResponse);
}
// -- Ping --------------------------------------------------------------------
message PingRequest {}
message PingResponse {
string version = 1;
// TrustMode the daemon is configured for: "catalog-validated" or
// "connector-trusted". Connector-trusted is dev-only.
string trust_mode = 2;
}
// -- Request -----------------------------------------------------------------
message ParquetPushdownRequest {
string table = 1;
int64 snapshot_id = 2;
// The authoritative list of files to scan, already resolved by the
// client's Iceberg planner. The server validates these against the
// catalog when running in catalog-validated trust mode.
repeated DataFileDescriptor data_files = 3;
// Columns to project. Identified by Iceberg field id (preferred)
// or path hint (fallback for non-Iceberg-managed Parquet).
repeated ColumnRef columns = 4;
PredicateKind predicate_kind = 5;
// Predicate is a *bound* expression: every column reference must
// resolve to an Iceberg field id (Substrait field-reference IDs
// matching schema_id, or Iceberg Expression JSON with id-based
// references). Name-only references are rejected.
bytes predicate = 6;
VectorQuery vector_query = 7;
int32 limit = 8;
// If true, the response may include per-row refs (RowRef list).
// Bounded by max_row_ids.
bool request_row_ids = 9;
int32 max_row_ids = 10;
// schema_id is the Iceberg schema id the predicate (and any
// ColumnRef.field_id values) are bound to. The server confirms it
// matches the snapshot's current_schema_id (or a known historical
// schema) before evaluating. Field IDs not present in this schema
// cause the request to be rejected.
int32 schema_id = 11;
}
message DataFileDescriptor {
string path = 1;
int64 size_bytes = 2; // Iceberg manifest file_size_in_bytes
int64 record_count = 3; // Iceberg manifest record_count
string etag = 4; // optional, when no Iceberg manifest
// Iceberg manifest entry's data_sequence_number; drives
// delete-file applicability (NOT file_sequence_number).
int64 data_sequence_number = 5;
// Partition spec id and serialized partition values; required to
// match delete-file applicability for partitioned tables.
int32 partition_spec_id = 6;
bytes partition_values = 7;
// Position-delete files, equality-delete files, and deletion
// vectors that apply to this data file. Discriminated by
// DeleteFileRef.content + DeleteFileRef.file_format.
repeated DeleteFileRef deletes = 8;
}
message DeleteFileRef {
string path = 1;
int64 size_bytes = 2;
// Iceberg manifest entry's data_sequence_number for this delete
// file. Applicability rule:
// data_file.seq <= delete_file.seq for position deletes / DVs
// data_file.seq < delete_file.seq for equality deletes
int64 data_sequence_number = 3;
// Partition spec id and serialized partition values; must match
// the data file's partition for the delete to apply.
int32 partition_spec_id = 4;
bytes partition_values = 5;
DeleteContent content = 6; // POSITION_DELETES / EQUALITY_DELETES
FileFormat file_format = 7; // PARQUET / AVRO / ORC / PUFFIN
// Required for content == EQUALITY_DELETES, empty otherwise.
repeated int32 equality_field_ids = 8;
// Puffin-only: when content == POSITION_DELETES and file_format
// == PUFFIN (deletion vectors), the DV blob lives at
// (path, blob_offset, blob_length). blob_crc32 is the Puffin
// footer's per-blob CRC, used for tamper detection (not as
// identity for cache lookup).
int64 blob_offset = 9;
int64 blob_length = 10;
uint32 blob_crc32 = 11;
// When this delete file targets one specific data file (mandatory
// for v3 deletion vectors). Empty when the delete file may target
// many data files.
string referenced_data_file = 12;
}
enum DeleteContent {
DELETE_CONTENT_UNSPECIFIED = 0;
POSITION_DELETES = 1; // Iceberg manifest content=1 (file or DV)
EQUALITY_DELETES = 2; // Iceberg manifest content=2
// Iceberg v3 deletion vectors are POSITION_DELETES with
// file_format == PUFFIN; there is no separate enum value.
}
enum FileFormat {
FILE_FORMAT_UNSPECIFIED = 0;
FILE_FORMAT_PARQUET = 1;
FILE_FORMAT_AVRO = 2;
FILE_FORMAT_ORC = 3;
FILE_FORMAT_PUFFIN = 4;
}
enum PredicateKind {
PREDICATE_KIND_UNSPECIFIED = 0;
PREDICATE_KIND_SUBSTRAIT = 1; // Substrait ExtendedExpression protobuf
PREDICATE_KIND_ICEBERG = 2; // Iceberg Expression JSON
}
enum VectorMetric {
VECTOR_METRIC_UNSPECIFIED = 0;
VECTOR_METRIC_L2 = 1;
VECTOR_METRIC_COSINE = 2;
VECTOR_METRIC_DOT = 3;
}
message VectorQuery {
ColumnRef column = 1;
repeated float vector = 2;
VectorMetric metric = 3;
int32 top_k = 4;
int32 nprobe = 5;
}
// ColumnRef identifies a column by Iceberg field id (stable across
// rename and reordering). Path is an optional hint used only when
// field_id == 0 (e.g. non-Iceberg Parquet that has no field ids).
message ColumnRef {
int32 field_id = 1;
string path = 2;
}
// -- Response ----------------------------------------------------------------
message ParquetPushdownResponse {
repeated FileRange file_ranges = 1;
repeated RowGroupRef row_groups = 2;
repeated PageRef pages = 3;
// Optional, emitted only when request.request_row_ids is true and
// the result fits within request.max_row_ids.
repeated ScoredRowRef row_refs = 4;
// True if a row-ref list was omitted or truncated due to size cap.
bool truncated = 5;
PushdownStats stats = 6;
}
message FileRange {
string file = 1;
int64 offset = 2;
int64 length = 3;
}
message RowGroupRef {
string file = 1;
int32 row_group = 2;
}
message PageRef {
string file = 1;
int32 row_group = 2;
ColumnRef column = 3;
int32 page = 4;
int64 offset = 5;
int64 length = 6;
}
// RowRef identifies a row by file-absolute position (matching Iceberg
// position-delete semantics). row_group is a locality hint, not
// authoritative.
message RowRef {
string file = 1;
int32 row_group = 2;
int64 file_position = 3;
}
message ScoredRowRef {
RowRef ref = 1;
// has_score distinguishes "no score available" from "score is
// 0.0", which is a legal vector-similarity value (zero L2
// distance, zero dot product). Non-vector queries leave
// has_score false; vector queries always set it true.
bool has_score = 2;
float score = 3;
}
message PushdownStats {
// The trust mode that serviced this request.
string trust_mode = 1;
// Server-side wall time spent on this request, in microseconds.
int64 server_time_micros = 2;
// Filled in by later milestones as the corresponding subsystems
// come online. Names use the design doc's per-feature labels.
int64 footer_cache_hits = 3;
int64 footer_cache_misses = 4;
int64 row_groups_pruned = 5;
int64 pages_pruned = 6;
int64 bytes_planned_scan = 7;
// Side indexes consulted vs missing, by index kind ("bloom",
// "bitmap", "btree", "page", "vector", ...). Useful both for
// observability and for the connector's cost-model feedback loop.
repeated string indexes_used = 8;
repeated string indexes_missing = 9;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.33.4
// source: parquet_pushdown.proto
package parquet_pushdown_pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
SeaweedParquetPushdown_Ping_FullMethodName = "/parquet_pushdown_pb.SeaweedParquetPushdown/Ping"
SeaweedParquetPushdown_Pushdown_FullMethodName = "/parquet_pushdown_pb.SeaweedParquetPushdown/Pushdown"
)
// SeaweedParquetPushdownClient is the client API for SeaweedParquetPushdown service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// SeaweedParquetPushdown is the gRPC surface of the standalone
// `weed pushdown` daemon. See PARQUET_PUSHDOWN_DESIGN.md and
// PARQUET_PUSHDOWN_DEV_PLAN.md for the surrounding design.
type SeaweedParquetPushdownClient interface {
// Ping returns daemon liveness information. Cheap; intended for
// smoke tests and connector health checks.
Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error)
// Pushdown takes a planner-resolved set of Iceberg data files and
// returns the byte ranges, row groups, pages, and (optionally) row
// refs that satisfy the request's predicate and vector clauses.
// M0 returns Unimplemented; later milestones fill in pruning logic.
Pushdown(ctx context.Context, in *ParquetPushdownRequest, opts ...grpc.CallOption) (*ParquetPushdownResponse, error)
}
type seaweedParquetPushdownClient struct {
cc grpc.ClientConnInterface
}
func NewSeaweedParquetPushdownClient(cc grpc.ClientConnInterface) SeaweedParquetPushdownClient {
return &seaweedParquetPushdownClient{cc}
}
func (c *seaweedParquetPushdownClient) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PingResponse)
err := c.cc.Invoke(ctx, SeaweedParquetPushdown_Ping_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *seaweedParquetPushdownClient) Pushdown(ctx context.Context, in *ParquetPushdownRequest, opts ...grpc.CallOption) (*ParquetPushdownResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ParquetPushdownResponse)
err := c.cc.Invoke(ctx, SeaweedParquetPushdown_Pushdown_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// SeaweedParquetPushdownServer is the server API for SeaweedParquetPushdown service.
// All implementations must embed UnimplementedSeaweedParquetPushdownServer
// for forward compatibility.
//
// SeaweedParquetPushdown is the gRPC surface of the standalone
// `weed pushdown` daemon. See PARQUET_PUSHDOWN_DESIGN.md and
// PARQUET_PUSHDOWN_DEV_PLAN.md for the surrounding design.
type SeaweedParquetPushdownServer interface {
// Ping returns daemon liveness information. Cheap; intended for
// smoke tests and connector health checks.
Ping(context.Context, *PingRequest) (*PingResponse, error)
// Pushdown takes a planner-resolved set of Iceberg data files and
// returns the byte ranges, row groups, pages, and (optionally) row
// refs that satisfy the request's predicate and vector clauses.
// M0 returns Unimplemented; later milestones fill in pruning logic.
Pushdown(context.Context, *ParquetPushdownRequest) (*ParquetPushdownResponse, error)
mustEmbedUnimplementedSeaweedParquetPushdownServer()
}
// UnimplementedSeaweedParquetPushdownServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedSeaweedParquetPushdownServer struct{}
func (UnimplementedSeaweedParquetPushdownServer) Ping(context.Context, *PingRequest) (*PingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
}
func (UnimplementedSeaweedParquetPushdownServer) Pushdown(context.Context, *ParquetPushdownRequest) (*ParquetPushdownResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Pushdown not implemented")
}
func (UnimplementedSeaweedParquetPushdownServer) mustEmbedUnimplementedSeaweedParquetPushdownServer() {
}
func (UnimplementedSeaweedParquetPushdownServer) testEmbeddedByValue() {}
// UnsafeSeaweedParquetPushdownServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to SeaweedParquetPushdownServer will
// result in compilation errors.
type UnsafeSeaweedParquetPushdownServer interface {
mustEmbedUnimplementedSeaweedParquetPushdownServer()
}
func RegisterSeaweedParquetPushdownServer(s grpc.ServiceRegistrar, srv SeaweedParquetPushdownServer) {
// If the following call pancis, it indicates UnimplementedSeaweedParquetPushdownServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&SeaweedParquetPushdown_ServiceDesc, srv)
}
func _SeaweedParquetPushdown_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PingRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedParquetPushdownServer).Ping(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedParquetPushdown_Ping_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedParquetPushdownServer).Ping(ctx, req.(*PingRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SeaweedParquetPushdown_Pushdown_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ParquetPushdownRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SeaweedParquetPushdownServer).Pushdown(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SeaweedParquetPushdown_Pushdown_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SeaweedParquetPushdownServer).Pushdown(ctx, req.(*ParquetPushdownRequest))
}
return interceptor(ctx, in, info, handler)
}
// SeaweedParquetPushdown_ServiceDesc is the grpc.ServiceDesc for SeaweedParquetPushdown service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var SeaweedParquetPushdown_ServiceDesc = grpc.ServiceDesc{
ServiceName: "parquet_pushdown_pb.SeaweedParquetPushdown",
HandlerType: (*SeaweedParquetPushdownServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Ping",
Handler: _SeaweedParquetPushdown_Ping_Handler,
},
{
MethodName: "Pushdown",
Handler: _SeaweedParquetPushdown_Pushdown_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "parquet_pushdown.proto",
}