feat: Materialization Metrics Capture - #374
Conversation
Add a write-time metrics capture layer for materialization (EAPC-22385), local compute engine + Cassandra first. Off by default; gated behind the ENABLE_MATERIALIZATION_METRICS env var. - MaterializationMetricsAggregator (feast/_materialization_metrics.py): accumulates rows_read_offline, rows_written_online, drop_reasons, fields_written, field_null_counts, and max_event_timestamp/lag_seconds. Invariant: rows_read - rows_written == rows_dropped == sum(drop_reasons). - ExecutionContext carries an optional metrics_collector; instantiated in ComputeEngine.get_execution_context only for a MaterializationTask when the env gate is on. - Local nodes populate it: LocalSourceReadNode (rows_read), LocalFilterNode (filter drops), LocalDedupNode (dedup drops), LocalOutputNode (rows_written, fields, null counts, freshness). All no-ops when the collector is absent. - The online store reaches the aggregator via a ContextVar the output node binds around the write (collecting()), so online_write_batch's signature is unchanged. Cassandra records ttl_expired / ttl_exceeds_max at its TTL skip points, best-effort. - Unit tests for the aggregator and the node hooks, incl. the contextvar store-drop seam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olume metrics (EAPC-22385) Extend the write-time materialization metrics collector to the Spark compute engine and add the remaining Layer-1 volume metrics. - Spark path: MaterializationStatsAccumulatorParam merges per-executor stats (via the pure, commutative merge_stats) back to the driver; map_in_arrow builds a per-partition aggregator bound through collecting() so the online store's TTL-skip drops are captured on the executor. - merge_from_dict folds the accumulator result into the driver-side collector. - Volume metrics: bytes_written (Arrow nbytes, both engines) and distinct_entity_keys (exact via pyarrow group_by on the local engine; left unset on Spark to avoid a second DAG-re-executing pass -- HLL follow-up). - Layer-1 -> job bridge (record_run_result / drain_run_results) so the materialization job can drain write-time stats after store.materialize(). - Env-gated (ENABLE_MATERIALIZATION_METRICS), no-op when disabled. Tests: collector merge/volume/bridge + Spark accumulator path (pyspark-guarded). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…2385) - Type map_in_arrow's stats_accumulator as Accumulator (the runtime object returned by sparkContext.accumulator), not the AccumulatorParam merge-rule class -- AccumulatorParam has no .add(). - Assert getActiveSession() is not None before use in SparkWriteNode (a write node only runs against an active session), resolving the union-attr error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of all-NULL Layer-1 fields (rows_read/written/dropped, fields_written,
field_null_counts, bytes, max_event_timestamp) on the Spark engine: the per-
executor tally was pushed into a Spark accumulator inside a mapInArrow UDF, and
accumulator updates from Arrow/pandas UDFs do not reliably propagate to the
driver (Spark wires accumulator updates through RDD actions, not the SQL/UDF
path). So stats_accumulator.value came back {} and merge_from_dict set nothing.
(The local engine tallies in-process on the driver, which is why it populated.)
Fix: map_in_arrow_online_stats does the same single-pass online write but RETURNS
one pickled MaterializationMetricsAggregator.to_dict() per partition as a binary
column; the driver .collect()s these and folds them with merge_stats -- a
returned result is guaranteed to reach the driver. Pickle preserves datetimes /
Counter / list fields. distinct_entity_keys stays unset on Spark (v1, unchanged).
Remove the now-dead accumulator machinery: MaterializationStatsAccumulatorParam,
the stats_accumulator plumbing in map_in_arrow (reverts it to a plain write
passthrough), and the unused imports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier Layer-1 work only instrumented SparkWriteNode (the DAG path, from_offline_store=False). Batch materialization actually runs SparkComputeEngine._materialize_from_offline_store (from_offline_store=True), which writes via map_in_pandas and had NO metrics capture -- so every Layer-1 field (rows_read/written/dropped, fields_written, field_null_counts, bytes, max_event_timestamp) was NULL regardless of the SparkWriteNode fix. Add map_in_pandas_online_stats: mirrors map_in_pandas' write exactly (incl. field mapping), tallies a MaterializationMetricsAggregator per partition, and RETURNS the pickled to_dict() in a binary column so the driver .collect()s and folds them with merge_stats (a returned result propagates; a Spark accumulator updated inside a pandas UDF does not). _materialize_from_offline_store now uses it (+ .collect()) when metrics are enabled, else the unchanged map_in_pandas path. Semantics: rows_written_online = rows submitted minus store-reported skips (the Cassandra store reports negative-/oversized-TTL skips as drops via collecting(), decrementing rows_written), so rows_read - rows_written == rows_dropped. This is a producer-side count, NOT an independent read-back verification (see follow-up). Best-effort: per-batch tally wrapped in try/except (never blocks the write); driver-side merge/record wrapped too. The .collect() write action is unguarded so real write failures still fail the job. distinct_entity_keys stays NULL on Spark (v1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… fixes distinct_entity_keys was NULL on Spark (per-partition exact counts can't be summed across partitions, and a second agg action would re-execute the uncached lineage). Compute it with Spark's built-in HLL++ instead: attach an Observation (approx_count_distinct over the join keys, struct() for composite keys) to the existing write action in BOTH Spark paths (_materialize_from_offline_store and SparkWriteNode), read obs.get after the action, and feed the existing set_distinct_entity_keys. One pass, no extra scan; approximate on Spark, exact on local. Unnamed Observation (auto-unique) so multi-FV sessions can't collide. Best-effort: any failure leaves the field NULL; observe() is a passthrough for the data. Spiked locally on pyspark 3.5.5: dek=103 for 100 true distinct, struct keys OK, empty-df safe, two sequential FVs in one session OK. Behavior-parity fixes in map_in_pandas_online_stats (metrics must never change the write): mirror map_in_pandas' empty-batch semantics (a bare return ends the partition -- now break, still emitting the partition's stats row) and its executor-side suppress_warnings block. Rewrite test_utils_metrics.py for the collect-based architecture -- it still imported the deleted MaterializationStatsAccumulatorParam / stats_accumulator param, which would have failed CI collection (hidden locally by importorskip). New tests cover both stats UDFs, store-drop contextvar binding, the metrics-free UDF, and the empty-batch parity. 46 metrics tests green locally with pyspark. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
make lint-python's ruff format --check flagged 5 files; formatting only, no logic change. 46 metrics tests green after formatting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove distinct_entity_keys end-to-end. On Spark it was only ever computable as an HLL++ approximation, and the first real run returned 1026 for a true 1000 -- an estimate that EXCEEDED rows_written_online, violating the distinct<=rows invariant and confusing the first consumer. The exact-value alternative proved the column redundant: on the from_offline_store path pull_latest dedups to one row per key, so distinct == rows_read_offline verbatim. Removed the Observation blocks (compute.py, nodes.py), the aggregator field/setter, the local-engine group_by, and the merge_stats/merge_from_dict/to_dict keys. Concept preserved as a docstring note: for normal FVs, rows_written_online approximates entities refreshed. Also harden get_execution_context: the metrics-collector construction runs OUTSIDE the callers' materialize try/except (_materialize_one calls it before its try), so a failure there would have crashed materialization instead of a clean job error. Wrap it so any failure degrades to metrics_collector=None and the run proceeds. Add the module logger this needed. 43 metrics tests green; CI-pinned ruff (0.14.1) format+check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…view)
Pre-review cleanup of the materialization-metrics code. No behavior change on
the happy path; hardens the failure paths.
Correctness (best-effort invariant -- a metrics-only failure must never flip a
successful write to ERROR):
- SparkWriteNode: the driver-side fold + record_run_result AFTER the .collect()
write action was UNGUARDED (compute.py already guarded the equivalent). A throw
there propagated into _materialize_one's except -> ERROR on a good write. Now
wrapped in try/except-warn.
- map_in_arrow_online_stats: the per-batch tally was unguarded (the pandas sibling
guarded it) -> now wrapped. Both stats UDFs now build their terminal pickled
payload defensively (fall back to pickle.dumps({}) ) so a serialization error
after successful writes can't fail the partition.
Redundancy (DRY):
- Add build_aggregator() and fold_stats_rows() to _materialization_metrics.py;
use build_aggregator at all 4 collector-construction sites (base.py, compute.py,
both write UDFs) and fold_stats_rows at both driver-side folds (compute.py,
nodes.py). Removes 4x + 2x copy-paste.
- Hoist the per-UDF 'import pickle' to module scope in utils.py.
43 metrics tests + 30 compute-engine tests green; CI-pinned ruff (0.14.1)
check+format clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… var (EAPC-22385) Replace the ENABLE_MATERIALIZATION_METRICS env-var gate with a declarative feature_store.yaml flag: batch_engine.metrics_enabled. Enablement is now a single config surface (no spark.kubernetes.driverEnv.* quirk), identical for Dataproc and Flyte, and version-controlled with the rest of the Feast config. - is_materialization_metrics_enabled(repo_config) reads batch_engine.metrics_enabled (duck-typed getattr; safe for any engine config and when repo_config is None). The env var is no longer consulted. - Add metrics_enabled: bool = False to SparkComputeEngineConfig and LocalComputeEngineConfig. - Pass repo_config at the two call sites (base.get_execution_context, spark compute). - Drop the now-unused os import + _TRUTHY; refresh module/field/context docstrings. - Tests: TestConfigGate covers config-on/off, missing field, None, and asserts the removed env var is ignored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… RepoConfig) Move the enablement flag from batch_engine.metrics_enabled to a top-level metrics namespace: metrics.materialization.enabled. RepoConfig is extra="allow", so an unknown metrics block is tolerated by older/other consumers (feature server, Go server, older SDK) rather than rejected -- unlike the engine configs (extra="forbid"), where the previous placement would have broken older eg-feast on parse. The nested sub-models extend FeastBaseModel (extra="allow") so future metrics.* keys stay forward-compatible. - repo_config.py: add MetricsConfig + MaterializationMetricsConfig models and a metrics field on RepoConfig (default-factory; disabled by default). - is_materialization_metrics_enabled(repo_config) reads metrics.materialization.enabled. - Drop the metrics_enabled field from Spark/Local engine configs (no longer used). - Update dag/context + module docstrings; TestConfigGate covers the nested shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| ) | ||
|
|
||
| # Feature view finished writing: hand the completed stats to the job bridge. | ||
| if collector is not None: |
There was a problem hiding this comment.
This record_run_result(collector.to_dict()) call (and the same call in the empty-batch early-return path above) isn't wrapped in try/except, unlike the equivalent Spark paths (spark/compute.py, spark/nodes.py), which were hardened in a later commit in this PR ("close best-effort guard gaps"). As written, if metrics assembly raises here — e.g. self.feature_view.features is misconfigured, or to_dict()/Counter merge fails — the exception propagates out of execute() after the actual online/offline write already succeeded, turning a successful materialization into a job failure.
Suggest porting the same try/except-and-warn guard used in spark/compute.py/spark/nodes.py to this local-engine path (both the empty-batch call and this one).
| input_table = self.get_single_table(context).data | ||
| context.node_outputs[self.name] = input_table | ||
|
|
||
| collector = context.metrics_collector |
There was a problem hiding this comment.
This tally (record_written + observe_written_batch) runs unconditionally, before the if self.feature_view.online: check below. For an offline-only feature view (online=False, offline=True), rows_written_online will still be set to the full row count even though no online write ever happens — a false signal that an online write occurred, with rows_dropped == 0.
Contrast with SparkWriteNode, where the equivalent tally is correctly scoped inside the online-write branch. Recommend moving this block (and the corresponding observe_written_batch call) inside if self.feature_view.online: for consistency with the Spark path and the actual rows_written_online semantics.
What this PR does / why we need it:
Which issue(s) this PR fixes:
Misc