Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0db12a6
fix: handle null-valued records in KafkaActionStateStore.rebuildState()
rob-9 Jul 8, 2026
98b1aaa
feat: send tombstone records in KafkaActionStateStore.pruneState()
rob-9 Jul 8, 2026
2c033e7
feat: make tombstone emission opt-in and harden pruning path
rob-9 Jul 14, 2026
e434753
test: cover async tombstone send failures and unparseable keys in pru…
rob-9 Jul 15, 2026
a02a28a
style: fix spotless formatting violations
rob-9 Jul 15, 2026
55f7874
Merge fork/main into feat/691-kafka-prune-tombstones
rob-9 Aug 21, 2026
41c9fee
feat: add kafkaActionStateTombstoneEnabled to python core options
rob-9 Aug 3, 2026
07958ff
test: exercise the real async tombstone send failure path
rob-9 Aug 3, 2026
f46cffc
docs: document that keys containing '_' are never pruned or tombstoned
rob-9 Aug 3, 2026
2cb557c
refactor: drop redundant producer.flush() in pruneState
rob-9 Aug 3, 2026
2cb89d8
docs: clarify tombstone recovery scope
rob-9 Aug 21, 2026
14b894f
test: keep tombstone assertions in the dedicated test, revert testPru…
rob-9 Aug 3, 2026
16b44b6
[runtime][docs] Harden tombstone recovery edge cases
rob-9 Aug 21, 2026
d09d94c
[runtime][java] Protect Fluss cleanup from key collisions
rob-9 Aug 23, 2026
a8bf18a
[runtime][java] Strengthen unparseable Kafka key test
rob-9 Aug 23, 2026
fca59c2
[runtime][java] Quiet repeated Kafka key parse failures
rob-9 Aug 23, 2026
8b8db63
[api][runtime][docs] Clarify action state key limitation
rob-9 Aug 23, 2026
a6c68e3
[docs] Restore exactly-once action wording
rob-9 Aug 23, 2026
298e44b
[runtime][java] Scope Kafka divergence cleanup by key
rob-9 Aug 24, 2026
53ca826
[runtime][java] Quiet retained Fluss state keys
rob-9 Aug 24, 2026
3435e07
[docs] Restore action recovery explanation
rob-9 Aug 24, 2026
ad61517
[runtime][java] Scope divergence detection by key
rob-9 Aug 24, 2026
69fa856
[runtime][java] Support underscores in action state keys
rob-9 Aug 24, 2026
1f371bd
[runtime] Merge main into Kafka tombstone branch
rob-9 Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ public enum ConditionEvaluationFailureStrategy {
public static final ConfigOption<Integer> KAFKA_ACTION_STATE_TOPIC_REPLICATION_FACTOR =
new ConfigOption<>("kafkaActionStateTopicReplicationFactor", Integer.class, 1);

/**
* The config parameter determines whether pruning sends tombstone (null-valued) records to the
* Kafka action state topic so log compaction can reclaim pruned keys. Defaults to {@code
* false}: disabling this option does not invalidate older restore points through pruning, but
* the topic continues to grow. When enabled, the checkpoint whose completion triggers pruning
* remains usable, but restoring an earlier checkpoint or savepoint may replay tombstones
* written after that restore point, erasing action state the replay still needs and causing
* already completed actions to re-execute. Enable only if the job never restores from earlier
* 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added matching Python option.

new ConfigOption<>("kafkaActionStateTombstoneEnabled", Boolean.class, false);

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.

Defaulting to false reads as the right call to me, and I think for a sharper reason than the javadoc gives itself credit for: with cleanup.policy=compact on the topic (KafkaActionStateStore.java:396), a tombstone is a durable delete instruction to the log cleaner — so the older-checkpoint erasure you documented above isn't something the replay side could have absorbed. Gating emission is the lever that actually controls it. A safe default-on would seem to need emission gated on the oldest still-restorable checkpoint, and I can't see a cheap way to know that here, so I'm not suggesting you flip it.

That does leave #691's original ask — the unbounded growth — unaddressed while the option is off. Where do you see this landing: is opt-in the endpoint, or would a follow-up be worth filing so the issue has somewhere to point?

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.

Saw #1034, that answers this. It's the right home for what #691 still has open, so I'll follow the discussion there.


/** The config parameter specifies the Fluss bootstrap servers. */
public static final ConfigOption<String> FLUSS_BOOTSTRAP_SERVERS =
new ConfigOption<>("flussBootstrapServers", String.class, "localhost:9123");
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/operations/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Here are the configuration options for Kafka-based Action State Store.
| `kafkaActionStateTopic` | (none) | String | The config parameter specifies the Kafka topic for action state. |
| `kafkaActionStateTopicNumPartitions`| 64 | Integer | The config parameter specifies the number of partitions for the Kafka action state topic. |
| `kafkaActionStateTopicReplicationFactor` | 1 | Integer | The config parameter specifies the replication factor for the Kafka action state topic. |
| `kafkaActionStateTombstoneEnabled` | false | Boolean | Whether pruning sends tombstone records so log compaction can reclaim pruned keys on a compacted action-state topic. Off by default: pruning does not invalidate older restore points, but the topic continues to grow. When enabled, the checkpoint whose completion triggers pruning remains usable, but restoring an earlier checkpoint or savepoint may replay later tombstones and re-execute already completed actions. Enable only if the job never restores from earlier checkpoints or savepoints, or if re-executing actions is acceptable. |

#### Fluss-based Action State Store

Expand Down
4 changes: 4 additions & 0 deletions docs/content/docs/operations/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ The same persisted action state is also used by fine-grained durable execution.

See [Action State Store Configuration]({{< ref "docs/operations/configuration#action-state-store" >}}) for configuration options.

{{< hint warning >}}
**Note**: Enabling Kafka action-state tombstones can invalidate checkpoints or savepoints older than the prune and cause completed actions to execute again. See [Action State Store Configuration]({{< ref "docs/operations/configuration#action-state-store" >}}) for the recovery trade-off.
{{< /hint >}}

{{< hint info >}}
**Note**: Exactly-once action consistency is guaranteed only if, after recovering from the same checkpoint, inputs for each key arrive in the same order as before recovery. If this ordering requirement is not met, the system falls back to exactly-once output consistency.
{{< /hint >}}
6 changes: 6 additions & 0 deletions python/flink_agents/api/core_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ class AgentConfigOptions:
default=1,
)

KAFKA_ACTION_STATE_TOMBSTONE_ENABLED = ConfigOption(
key="kafkaActionStateTombstoneEnabled",
config_type=bool,
default=False,
)

FLUSS_BOOTSTRAP_SERVERS = ConfigOption(
key="flussBootstrapServers",
config_type=str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ void put(Object key, long seqNum, Action action, Event event, ActionState state)
/**
* Prune the state for a given key.
*
* <p>Implementations must at least evict the matching entries from the in-memory cache. Whether
* the backend storage is also cleaned up is implementation-specific. Durable deletion can
* invalidate checkpoints or savepoints whose recovery markers precede the deletion: {@link
* #rebuildState(List)} replays the backend from the restored recovery marker, so records
* deleted after that marker may be state the replay still needs. Implementations must either
* enforce a recovery boundary that protects every supported restore point or clearly document
* the recovery trade-off of advancing beyond that boundary.
*
* @param key the key whose state should be pruned
* @param seqNum the sequence number up to which the state should be pruned
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import java.util.concurrent.TimeUnit;
import java.util.function.IntPredicate;

import static org.apache.flink.agents.api.configuration.AgentConfigOptions.KAFKA_ACTION_STATE_TOMBSTONE_ENABLED;
import static org.apache.flink.agents.api.configuration.AgentConfigOptions.KAFKA_ACTION_STATE_TOPIC;
import static org.apache.flink.agents.api.configuration.AgentConfigOptions.KAFKA_ACTION_STATE_TOPIC_NUM_PARTITIONS;
import static org.apache.flink.agents.api.configuration.AgentConfigOptions.KAFKA_ACTION_STATE_TOPIC_REPLICATION_FACTOR;
Expand Down Expand Up @@ -92,6 +93,9 @@ public class KafkaActionStateStore implements ActionStateStore {
// Kafka topic that stores action states
private final String topic;

// Whether pruning sends tombstone records for log compaction
private final boolean tombstoneEnabled;

// When set, only records whose key-group is accepted by this predicate are kept in the
// in-memory cache during rebuildState; null means retain all keys (default).
private IntPredicate ownershipFilter;
Expand All @@ -113,6 +117,7 @@ public class KafkaActionStateStore implements ActionStateStore {
this.topic = topic;
this.latestKeySeqNum = new HashMap<>();
this.agentConfiguration = agentConfiguration;
this.tombstoneEnabled = agentConfiguration.get(KAFKA_ACTION_STATE_TOMBSTONE_ENABLED);
this.maxParallelism = maxParallelism;
}

Expand All @@ -127,6 +132,7 @@ public KafkaActionStateStore(AgentConfiguration agentConfiguration, int maxParal
this.actionStates = new HashMap<>();
this.latestKeySeqNum = new HashMap<>();
this.agentConfiguration = agentConfiguration;
this.tombstoneEnabled = agentConfiguration.get(KAFKA_ACTION_STATE_TOMBSTONE_ENABLED);
this.topic =
Preconditions.checkNotNull(
agentConfiguration.get(KAFKA_ACTION_STATE_TOPIC),
Expand Down Expand Up @@ -256,17 +262,17 @@ public void rebuildState(List<Object> recoveryMarkers) {
break;
}

// Deserialization failures throw from poll() itself and are handled by the
// outer catch, so records here are always fully deserialized.
for (ConsumerRecord<String, ActionState> record : records) {
try {
if (!ActionStateUtil.isKeyRetained(ownershipFilter, record.key())) {
continue;
}
if (!ActionStateUtil.isKeyRetained(ownershipFilter, record.key())) {
continue;
}
if (record.value() == null) {
// Tombstone record - remove the key from cache
actionStates.remove(record.key());
} else {
actionStates.put(record.key(), record.value());
} catch (Exception e) {
LOG.warn(
"Failed to deserialize action state record: {}",
record.value().toString(),
e);
}
}

Expand All @@ -288,14 +294,50 @@ public void setOwnershipFilter(IntPredicate ownershipFilter) {
public void pruneState(Object key, long seqNum) {
LOG.debug("Pruning state for key: {} up to sequence number: {}", key, seqNum);

// Remove states from in-memory cache for this key up to the specified sequence
// number
actionStates
.keySet()
.removeIf(
cachedKey ->
ActionStateUtil.matchesBusinessKeyWithSeqNum(
cachedKey, key, stateSeqNum -> stateSeqNum <= seqNum));
// Collect state keys belonging to this key with sequence number <= seqNum.
List<String> keysToPrune = new ArrayList<>();
for (String stateKey : actionStates.keySet()) {
if (ActionStateUtil.matchesBusinessKeyWithSeqNum(
stateKey, key, stateSeqNum -> stateSeqNum <= seqNum)) {
keysToPrune.add(stateKey);
}
}

// Send tombstones to Kafka so log compaction can reclaim storage; opt-in because
// tombstones break replay when restoring a checkpoint/savepoint older than the prune
// (see KAFKA_ACTION_STATE_TOMBSTONE_ENABLED). Send failures surface asynchronously,
// so report them via callback; the records then persist until manual cleanup.
if (tombstoneEnabled && producer != null && !keysToPrune.isEmpty()) {
try {
for (String stateKey : keysToPrune) {
producer.send(
new ProducerRecord<>(topic, stateKey, null),
(metadata, exception) -> {
if (exception != null) {
LOG.warn(
"Failed to send tombstone record for state key: {}. "
+ "The record will persist in the topic "
+ "until manual cleanup.",
stateKey,
exception);
}
});
}
LOG.debug(
"Queued {} tombstone records to Kafka for key: {}",
keysToPrune.size(),
key);
} catch (Exception e) {
LOG.warn(
"Failed to send tombstone records to Kafka for key: {}. "
+ "Records will persist in the topic until manual cleanup.",
key,
e);
}
}

// Remove from in-memory cache (always, regardless of tombstone success)
actionStates.keySet().removeAll(keysToPrune);

LOG.debug("Pruned state for key: {} up to sequence number: {}", key, seqNum);
}
Expand Down
Loading
Loading