Skip to content

[runtime] send Kafka tombstones on state pruning for durable log compaction#885

Open
rob-9 wants to merge 5 commits into
apache:mainfrom
rob-9:feat/691-kafka-prune-tombstones
Open

[runtime] send Kafka tombstones on state pruning for durable log compaction#885
rob-9 wants to merge 5 commits into
apache:mainfrom
rob-9:feat/691-kafka-prune-tombstones

Conversation

@rob-9

@rob-9 rob-9 commented Jul 8, 2026

Copy link
Copy Markdown

Addresses #691

Purpose of change

KafkaActionStateStore.pruneState() only removed entries from the in-memory cache. the underlying Kafka topic was never updated, so:

  1. the topic grew unbounded, meaning Kafka log compaction had no tombstones to trigger key deletion
  2. after a restart, rebuildState() replayed pruned records back into memory, resurrecting state that should have been discarded

This PR sends null-valued records to the state topic during pruning, and updates rebuildState() to skip tombstoned keys during replay instead of inserting them into the cache. Also fixes an NPE in the error-logging path that called record.value().toString() on a null value.

Tests

mvn --batch-mode --no-transfer-progress -pl runtime -am -Dtest=KafkaActionStateStoreTest test

  • testPruneState verifies tombstones are produced and in-memory entries are evicted
  • testPruneStateSendsTombstonesWithCorrectKeys asserts exact composite keys on produced tombstones
  • testPruneStateNoMatchingKeys verifies no tombstones sent when no keys match
  • testPruneStateWithNullProducer verifies graceful degradation when producer is null

API

No public API changes.

Documentation

  • doc-not-needed

@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Jul 8, 2026
@wenjin272

Copy link
Copy Markdown
Contributor

@joeyutong Can you take a look at this PR?

@joeyutong

Copy link
Copy Markdown
Collaborator

Thanks for addressing the growth of Kafka action-state records. However, writing tombstones unconditionally may break recovery from older checkpoints or savepoints.

For example, if C0 is followed by action state S and then C1 tombstones S, restoring C0 will replay both S and the tombstone. rebuildState() removes S, so the action may execute again. This happens even when Kafka compaction is disabled.

Could tombstone emission be opt-in, for example through kafkaActionStateTombstoneEnabled defaulting to false?

@rob-9

rob-9 commented Jul 14, 2026

Copy link
Copy Markdown
Author

Could tombstone emission be opt-in, for example through kafkaActionStateTombstoneEnabled defaulting to false?

Yeah, agreed.

Can we make cleanup safe by default though? Maybe we could stamp tombstones with the checkpoint id that triggered the prune; rebuildState() applies one only if id <= restored checkpoint, so restoring C0 skips C1's tombstones. But this would only protect the record until compaction deletes it.

This can be a follow-up. I'll add the opt-in flag to this PR for now.

- add kafkaActionStateTombstoneEnabled (default false) so tombstone
  emission is opt-in; rebuildState still honors tombstones already in
  the topic regardless of the flag
- match the parsed key part exactly in pruneState so pruning key
  "a_1" can no longer tombstone state of the distinct key "a"
- report async tombstone send failures via producer callback (flush()
  does not surface per-record errors)
- narrow the prune catch to IllegalArgumentException and state the
  retention consequence in the warning
- remove dead inner try/catch in rebuildState (deserialization errors
  throw from poll(), not from the map ops it wrapped)
- document the durable-deletion replay constraint on
  ActionStateStore.pruneState and add the new option to the config docs
- tests: default-off pruning, prefix-collision regression, tombstone
  replay in rebuildState; simplify assertions
@joeyutong

Copy link
Copy Markdown
Collaborator

Thanks for raising the question of whether cleanup can be safe by default. A checkpoint-aligned, user-controlled cleanup path may be a safer general solution:

  1. Persist and expose the mapping from each checkpoint to its effective Kafka recovery marker—the per-partition minimum across all subtasks.
  2. Leave the irreversible cleanup decision to users. Based on their recovery requirements, they can choose the oldest checkpoint to retain and delete records before its marker offsets through Kafka's deleteRecords API.
  3. During rebuildState(), validate the requested offsets against Kafka's current beginning offsets. If a recovery offset is older than the available data, fail fast with a clear error instead of silently rebuilding incomplete state.

Since rebuildState() already starts from the recovery marker, the same marker is a natural boundary for physical cleanup. This aligns cleanup with the actual recovery semantics without automatically invalidating older checkpoints. Once users advance that boundary, fail-fast validation makes any incompatible recovery attempt explicit.

This could be explored as a follow-up.

rob-9 added 2 commits July 15, 2026 11:16
…neState

- testPruneStateEvictsCacheEvenWhenTombstoneSendFails: verifies pruneState
  degrades gracefully and still evicts the in-memory entry when a tombstone
  send fails asynchronously (the callback-reporting fix from the prior commit)
- testPruneStateSkipsUnparseableKeys: verifies a state key that cannot be
  parsed into 4 parts is retained rather than pruned (the narrowed
  IllegalArgumentException catch)

Both were verified to fail when the corresponding fix is reverted.
* completed actions to re-execute. Enable only if the job never restores from non-latest
* checkpoints or savepoints, or if re-executing actions is acceptable.
*/
public static final ConfigOption<Boolean> KAFKA_ACTION_STATE_TOMBSTONE_ENABLED =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also add the corresponding option to python/flink_agents/api/core_options.py? The cross-language option parity check currently fails because this field exists only on the Java side.

actionStateStore = tombstoneEnabledStore(actionStates, mockProducer);
String stateKey = ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, testEvent);
actionStates.put(stateKey, testActionState);
mockProducer.errorNext(new RuntimeException("simulated broker failure"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mockProducer uses autoComplete=true, so errorNext() is called before any pending completion exists and does not make the subsequent send() fail. This test therefore exercises the successful send path. Could we inject an exception through the callback so the async failure path is actually covered?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants