DUE TO SPAM, SIGN-UP IS DISABLED. Goto Selfserve wiki signup and request an account.
This page is meant as a template for writing a FIP. To create a FIP choose Tools->Copy on this page and modify with your content and replace the heading with the next FIP number and a description of your issue. Replace anything in italics with your own description.
Please keep the discussion on the mailing list rather than commenting on the wiki (wiki discussions get unwieldy fast).
Motivation
Currently, for Fluss PrimaryKey Table, the number of kv snapshots retained per bucket is controlled by the server option `kv.snapshot.num-retained` (default value: 1). If this value is set too small, Kv snapshots that are being actively consumed may be deleted while a consumer is still consuming them.
This case will cause a Flink job which read PrimaryKey table fail and cannot be restarted from its previous state.
To avoid this case happen, the fluss server needs to be aware of which consumers are actively consuming the corresponding kv snapshots, and can not delete any these kv snapshots that are currently being consumed.
To address this problem, This FIP will introduce a KV snapshot lease mechanism. When a consumer starts consuming a KV table, we create a lease for that consumer. This lease has an expiration time (ttl), however, within this expiration window, the KV snapshots "pinged" by the lease will not be cleaned up.
Public Interfaces
1. Admin APIs
To achieve the goal that expose APIs/admin commands for register/unregister and clear kv snapshot lease , this FIP will introduce four new APIs in Admin.
@PublicEvolving
public interface Admin extends AutoCloseable {
// --- the following is added method
/**
* Acquires a lease for specific KV snapshots of the given tableBuckets asynchronously.
* <p> Once acquired, the specified KV snapshots will be protected from garbage collection
* for the duration of the {@code leaseDuration}. The client must call
* {@link #renewKvSnapshotLease} periodically to keep the lease alive, or call
* {@link #releaseKvSnapshotLease} to release the lock early when reading is finished.
*
* <p>If the lease expires (no renew received within duration), the server is free to
* delete the snapshot files.
*
* <p>The following exceptions can be anticipated when calling {@code get()} on returned future:
* <ul>
* <li>{@link TableNotExistException} if the table does not exist.
* <li>{@link PartitionNotExistException} if the partition does not exist.
* </ul>
*
* @param leaseId The unique ID for this lease session (usually a UUID generated by client).
* @param snapshotIds The snapshots to consume, a map from TableBucket to kvSnapshotId.
* @param leaseDuration The duration (in milliseconds) for which the snapshots should be kept.
* @return The result of the acquire operation, containing any buckets that failed to be locked.
*/
CompletableFuture<AcquireKvSnapshotLeaseResult> acquireKvSnapshotLease(
String leaseId,
Map<TableBucket, Long> snapshotIds,
long leaseDuration);
/**
* Renews the lease for the given leaseId asynchronously.
*
* <p> This acts as a heartbeat. It resets the expiration time for ALL tableBuckets currently
* associated with this {@code leaseId}.
*
* <p>The following exceptions can be anticipated:
* <ul>
* <li>{@link KvSnapshotLeaseNotFoundException} if the leaseId does not exist or has already expired.
* </ul>
*
* @param leaseId The lease id to renew.
* @param leaseDuration The new duration (in milliseconds) from now.
*/
CompletableFuture<Void> renewKvSnapshotLease(
String leaseId,
long leaseDuration);
/**
* Releases the lease for specific tableBuckets asynchronously.
*
* <p> This is typically called when a client finishes reading a specific bucket (or a batch of buckets)
* but is still reading others under the same leaseId.
*
* <p>If {@code bucketsToRelease} contains all buckets under this leaseId, the lease
* itself will be removed.
*
* @param leaseId The lease id.
* @param bucketsToRelease The specific tableBuckets to release.
*/
CompletableFuture<Void> releaseKvSnapshotLease(
String leaseId,
Set<TableBucket> bucketsToRelease);
/**
* Drops the entire lease asynchronously.
*
* <p> All snapshots locked under this {@code leaseId} will be released immediately.
* This is equivalent to calling {@link #releaseKvSnapshotLease} with all held buckets.
*
* @param leaseId The lease id to drop.
*/
CompletableFuture<Void> dropKvSnapshotLease(String leaseId);
}
The class: AcquireKvSnapshotLeaseResult
/**
* A class to represent the result of acquire kv snapshot lease. It contains:
*
* <ul>
* <li>A map of unavailable snapshots. Such as the specify snapshotId is not exist for this table
* bucket.
* </ul>
*
* @since 0.9
*/
@PublicEvolving
public class AcquireKvSnapshotLeaseResult {
private final Map<TableBucket, Long> unavailableSnapshots;
public AcquireKvSnapshotLeaseResult(Map<TableBucket, Long> unavailableSnapshots) {
this.unavailableSnapshots = unavailableSnapshots;
}
/**
* Returns the set of buckets that could not be locked (e.g., snapshot ID doesn't exist
* or has already been GC'ed).
*/
public Map<TableBucket, Long> getUnavailableSnapshots() {
return unavailableSnapshots;
}
}
2. New ApiException
New Introduced API errors:
1.KvSnapshotLeaseNotFoundException
/**
* Kv snapshot lease not exist exception.
*
* @since 0.9
*/
public class KvSnapshotLeaseNotExistException extends ApiException {
private static final long serialVersionUID = 1L;
public KvSnapshotLeaseNotExistException(String message) {
super(message);
}
}
3. New FlussApis
For the new added Fluss API, this FIP will introduce four request/response in FlussApis named:
1. AcquireKvSnapshotLeaseRequest / AcquireKvSnapshotLeaseResponse
2. ReleaseKvSnapshotLeaseRequest / Release KvSnapshotLeaseResponse
3. DropKvSnapshotLeaseRequest / Drop KvSnapshotLeaseResponse
message AcquireKvSnapshotLeaseRequest {
required string lease_id = 1;
required int64 lease_duration = 2;
repeated PbKvSnapshotLeaseForTable tables_lease_req = 3;
}
message AcquireKvSnapshotLeaseResponse {
repeated PbKvSnapshotLeaseForTable tables_lease_res = 3;
}
// util class
message PbKvSnapshotLeaseForTable {
required int64 table_id = 1;
repeated PbKvSnapshotLeaseForBucket buckets_lease_req = 2;
}
message PbKvSnapshotLeaseForBucket {
optional int64 partition_id = 1;
required int32 bucket_id = 2;
required int64 snapshot_id = 3;
}
message ReleaseKvSnapshotLeaseRequest {
required string lease_id = 1;
repeated PbTable release_tables = 2;
}
message ReleaseKvSnapshotLeaseResponse {
}
// util class
message PbTable {
required int64 table_id = 1;
repeated PbBucket buckets = 2;
}
message PbBucket {
optional int64 partition_id = 1;
required int32 bucket_id = 2;
}
message DropKvSnapshotLeaseRequest {
required string lease_id = 1;
}
message DropKvSnapshotLeaseResponse {
}
4. New ZkPath and ZkData
And a new zk path /leases/kv_snapshot/[lease_id]
The path is as follow:
// ------------------------------------------------------------------------------------------
// ZNodes for Leases.
// ------------------------------------------------------------------------------------------
/** The root znode for leases. It will record all the info of fluss leases.
*
* <p> Leases are a type of time-bound subscription. For example, a kv snapshot lease with lease duration:
* Once the duration time is reached, the lease is automatically released, and the corresponding kv snapshot
* becomes eligible for deletion. However, as long as the lease remains active, the kv snapshot must not be deleted.
*/
public static final class LeasesNode {
public static String path() {
return "/leases";
}
}
/** The root znode for kv snapshot leases. */
public static final class KvSnapshotLeasesZNode {
public static String path() {
return LeasesNode.path() + "/kv_snapshot";
}
}
/** The znode for kv snapshot lease. */
public static final class KvSnapshotLeaseZNode {
public static String path(String leaseId) {
return KvSnapshotLeasesZNode.path() + "/" + leaseId;
}
public static byte[] encode(KvSnapshotLease kvSnapshotLease) {
return JsonSerdeUtils.writeValueAsBytes(
kvSnapshotLease, kvSnapshotLeaseJsonSerde.INSTANCE);
}
public static KvSnapshotLease decode(byte[] json) {
return JsonSerdeUtils.readValue(json, KvSnapshotLeaseJsonSerde.INSTANCE);
}
}
Here, we introduce a top-level `leases` directory, under which a subdirectory kv_snapshot is added, leaving room for future extensibility to support other lease-related features.
The znode `lease_id` content example is as follow:
// 1. the content of zkNode
{
"version": 1,
"expiration_time": 1735538268434,
"tables": [
{
"table_id": 1
"lease_metadata_path": "{$remote_path}/lease/kv-snapshot/{$lease_id}/{$table_id}/{UUID}.metadata"
},
{
"table_id": 2
"lease_metadata_path": "{$remote_path}/lease/kv-snapshot/{$lease_id}/{$table_id}/{UUID}.metadata"
}
]
}
// 2. the content of file: {$remote_path}/lease/kv-snapshot/{$lease_id}/{$table_id}/{UUID}.metadata
// 2.1 none-partition table
{
"version": 1,
"table_id": 1,
"bucket_snapshots": [1, -1, 1, 2] // the value is a sorted list for bucket -> snapshotId, -1 means no lease for this bucket.
}
// 2.2 partition table
{
"version": 1,
"table_id": 2,
"partition_snapshots":
[
{
"partition_id": 2001,
"bucket_snapshots": [10, -1, 20, 30] // the value is a sorted list for bucket -> snapshotId, -1 means no lease for this bucket.
},
{
"partition_id": 2002,
"bucket_snapshots": [15, -1, 25, 35]
}
]
}
Here, a remote file is used to store metadata instead of storing it in ZooKeeper to avoid making ZooKeeper nodes too large.
5. New fluss metrics
This FIP will introduce two new metrics in the CoordinatorServer to track the the current number of active kv snapshot leases, and the number of snapshots registered by lease.
coordinator_kvSnapshotLeaseCount: the current number of active kv snapshot leases.coordinator_leasedKvSnapshotCount: the number of snapshots registered by lease.
6. New fluss config
The new fluss configs:
kv.snapshot.lease.expiration-check-interval
public static final ConfigOption<Duration> KV_SNAPSHOT_LEASE_EXPIRATION_CHECK_INTERVAL =
key("kv.snapshot.lease.expiration-check-interval")
.durationType()
.defaultValue(Duration.ofMinutes(10))
.withDescription(
"The interval to check the expiration of kv snapshot leases. "
+ "The default setting is 10 minutes.");
7. New flink config
The new flink config:
scan.kv.snapshot.lease.id
scan.kv.snapshot.lease.duration
public static final ConfigOption<String> SCAN_KV_SNAPSHOT_LEASE_ID =
ConfigOptions.key("scan.kv.snapshot.lease.id")
.stringType()
.defaultValue(String.valueOf(UUID.randomUUID()))
.withDescription(
"The lease id to ping kv snapshots. If set, the registered kv snapshots will not be deleted "
+ "until the consumer finished consuming all the snapshots or the lease duration time "
+ "is reached. If not set, an UUID will be set.");
public static final ConfigOption<Duration> SCAN_KV_SNAPSHOT_LEASE_DURATION =
ConfigOptions.key("scan.kv.snapshot.lease.duration")
.durationType()
.defaultValue(Duration.ofDays(1))
.withDescription(
"The time period how long to wait before expiring the kv snapshot lease to "
+ "avoid kv snapshot blocking to delete. Default value is 1 day");
In Flink, if no lease-id is explicitly specified, a UUID will be used as the lease ID, and the lease duration time will default to 1 day:
SELECT * FROM `kv_table`/*OPTIONAL('scan.kv.snapshot.lease.id'='lease1', 'scan.kv.snapshot.lease.duration'='1d')*/
Proposed Changes
The previous section described the newly introduced APIs. This section explains how Fluss integrates with Flink to perform registration and unregistration of KV snapshot lease, ensuring that Flink jobs remain stable during execution.
1. Flink (Consumer) side
when to register kv snapshot lease?
When starting a Flink job
IninitNonPartitionedSplitsandinitPrimaryKeyTablePartitionSplits, after obtaining thekvSnapshots, it is necessary to callacquireKvSnapshotLeaseto complete the registration of the KV snapshot lease.When restoring a Flink job
IninitNonPartitionedSplitsandinitPrimaryKeyTablePartitionSplits, after obtaining thekvSnapshots, first determine which ones should be ignored. For the buckets that are not ignored,acquireKvSnapshotLeasemust be called to complete the registration of the KV snapshot lease.When a new partition is added
IninitPrimaryKeyTablePartitionSplits, after obtaining thekvSnapshots,acquireKvSnapshotLeasemust be called to register the KV snapshot lease for the current partition.- Note: Since
Admin#getLatestKvSnapshots(TablePath tablePath)andAdmin#acquireKvSnapshotLeaseare not atomic operations, the latest kv snapshot obtained viagetmay become outdated by the time it is passed toacquire. Therefore, for the short terms, the defaule value of 'kv.snapshot.num-retained' should be set 2 to avoid the latest kv snapshot is being deleted. For the long run, this logic must implement a retry mechanism: thefailedTableBucketSetin theAcquireKvSnapshotLeaseResultreturned byacquireKvSnapshotLeasemust be retried repeatedly until all buckets are successfully registered.
When to unregister kv snapshot lease for tableBuckets?
To minimize server pressure and unregister tableBucket from KV snapshots after completing the full-scan phase, we need to periodically report consumption status in FlinkSourceEnumerator by calling Admin#releaseKvSnapshotLease for unregister kv snapshot.
Since FlinkSourceEnumerator currently lacks support for identifying which splits have entered the incremental phase, a new Flink Source event—FinishedKvSnapshotConsumeEvent—must be introduced. FlinkSourceReader will periodically send this event about completed KV snapshot consumption to FlinkSourceEnumerator.
The event is defined as follow:
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.fluss.flink.source.event;
import org.apache.flink.api.connector.source.SourceEvent;
import org.apache.fluss.metadata.TableBucket;
import java.util.Objects;
import java.util.Set;
/** SourceEvent used to represent a Fluss table bucket has complete consume kv snapshot. */
public class FinishedKvSnapshotConsumeEvent implements SourceEvent {
private static final long serialVersionUID = 1L;
private final long checkpointId;
/** The tableBucket set who finished consume kv snapshots. */
private final Set<TableBucket> tableBuckets;
public FinishedKvSnapshotConsumeEvent(long checkpointId, Set<TableBucket> tableBuckets) {
this.checkpointId = checkpointId;
this.tableBuckets = tableBuckets;
}
public long getCheckpointId() {
return checkpointId;
}
public Set<TableBucket> getTableBuckets() {
return tableBuckets;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
FinishedKvSnapshotConsumeEvent that = (FinishedKvSnapshotConsumeEvent) o;
return checkpointId == that.checkpointId && tableBuckets.equals(that.tableBuckets);
}
@Override
public int hashCode() {
return Objects.hash(checkpointId, tableBuckets);
}
@Override
public String toString() {
return "FinishedKvSnapshotConsumeEvent{"
+ "checkpointId="
+ checkpointId
+ ", tableBuckets="
+ tableBuckets
+ '}';
}
}
The timing for FlinkSourceReader to send the FinishedKvSnapshotConsumeEvent is within the public List<SourceSplitBase> snapshotState(long checkpointId) method, where it is periodically sent.
public class FlinkSourceReader<OUT>
extends SingleThreadMultiplexSourceReaderBaseAdapter<
RecordAndPos, OUT, SourceSplitBase, SourceSplitState> {
...
/** the tableBuckets ignore to send FinishedKvSnapshotConsumeEvent as it already sending. */
private final Set<TableBucket> ignoreBuckets;
...
@Override
public List<SourceSplitBase> snapshotState(long checkpointId) {
Set<TableBucket> bucketsFinishedConsumeKvSnapshot = new HashSet<>();
// do not modify this state.
List<SourceSplitBase> sourceSplitBases = super.snapshotState(checkpointId);
for (SourceSplitBase sourceSplitBase : sourceSplitBases) {
TableBucket tableBucket = sourceSplitBase.getTableBucket();
if (ignoreBuckets.contains(tableBucket)) {
continue;
}
if (sourceSplitBase.isHybridSnapshotLogSplit()) {
HybridSnapshotLogSplit hybridSnapshotLogSplit =
sourceSplitBase.asHybridSnapshotLogSplit();
if (hybridSnapshotLogSplit.isSnapshotFinished()) {
bucketsFinishedConsumeKvSnapshot.add(tableBucket);
}
}
}
// report finished kv snapshot consume event.
if (!bucketsFinishedConsumeKvSnapshot.isEmpty()) {
context.sendSourceEventToCoordinator(
new FinishedKvSnapshotConsumeEvent(
checkpointId, bucketsFinishedConsumeKvSnapshot));
// It won't be sent anymore in the future for this table bucket, but will be resent
// after failover recovery as ignoreBuckets is cleared.
ignoreBuckets.addAll(bucketsFinishedConsumeKvSnapshot);
}
return sourceSplitBases;
}
}
After receiving the FinishedKvSnapshotConsumeEvent, the FlinkSourceEnumerator stores it in consumedKvSnapshotMap, which is a TreeMap keyed by checkpointId.
And then a request is sent to FlussAdmin in notifyCheckpointComplete periodical. A rough implementation is as follows:
public class FlinkSourceEnumerator
implements SplitEnumerator<SourceSplitBase, SourceEnumeratorState> {
....
private final String kvSnapshotLeaseId;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
/** checkpointId -> tableBuckets who finished consume kv snapshots. */
@GuardedBy("lock")
private final TreeMap<Long, Set<TableBucket>> consumedKvSnapshotMap = new TreeMap<>();
...
@Override
public void handleSourceEvent(int subtaskId, SourceEvent sourceEvent) {
if (sourceEvent instanceof ...)
else if (sourceEvent instanceof FinishedKvSnapshotConsumeEvent) {
FinishedKvSnapshotConsumeEvent event = (FinishedKvSnapshotConsumeEvent) sourceEvent;
long checkpointId = event.getCheckpointId();
event.getTableBuckets().forEach(tableBucket -> addConsumedBucket(checkpointId, tableBucket));
}
}
@Override
public void notifyCheckpointComplete(long checkpointId) throws Exception {
// lower than this checkpoint id.
Set<TableBucket> consumedKvSnapshots = getAndRemoveConsumedBucketsUpTo(checkpointId);
// send request to fluss admin to release the kv snapshot lease.
flussAdmin.releaseKvSnapshotLease(kvSnapshotLeaseId, consumedKvSnapshots).get();
}
/** Add bucket who has been consumed kv snapshot to the consumedKvSnapshotMap. */
public void addConsumedBucket(long checkpointId, TableBucket tableBucket) {
inWriteLock(
lock,
() -> {
consumedKvSnapshotMap
.computeIfAbsent(checkpointId, k -> new HashSet<>())
.add(tableBucket);
});
}
/** Get and remove the buckets who have been consumed kv snapshot up to the checkpoint id. */
public Set<TableBucket> getAndRemoveConsumedBucketsUpTo(long checkpointId) {
return inWriteLock(
lock,
() -> {
NavigableMap<Long, Set<TableBucket>> toRemove =
consumedKvSnapshotMap.headMap(checkpointId, false);
Set<TableBucket> result = new HashSet<>();
for (Set<TableBucket> snapshots : toRemove.values()) {
result.addAll(snapshots);
}
toRemove.clear();
return result;
});
}
}
Set the lease-id for the Tiering job
This FIP is temporarily out of scope and will be addressed as a separate issue in the future.
When to drop a kv snapshot lease
In certain scenarios, residual lease information is detected in zookeeper, Fluss cluster administrators can invoke the Flink call procedure to frop kv snapshot lease and release these subscribe kv snapshots.
Flink command design:
Call `fluss-catalog`.sys.drop_kv_snapshot_lease('snapsho-lease-1000');
2. Server side
Introduce an KvSnapshotLeaseManager
A new KvSnapshotLeaseManager is introduced to manage all kv snapshot leases info. When a snapshot is deleted, it checks whether there are any associated kv snapshot lease, and the snapshot is deleted only if no leases exist.
When to delete kv snapshot
- First, the default value of
kv.snapshot.num-retainedwill be set to2, and by default, only two snapshot will be retained afterward. - In addition to the existing deletion logic, the following logic will be added: When determining whether a snapshot can be deleted, the server must query the
KvSnapshotLeaseManagerto check if the snapshot is currently being leased. If it is, deletion will be disallowed.
Compatibility, Deprecation, and Migration Plan
- What impact (if any) will there be on existing users?
- If we are changing behavior how will we phase out the older behavior?
- If we need special migration tools, describe them here.
- When will we remove the existing behavior?
Test Plan
ITCase and Ut case will be added.
Rejected Alternatives
If there are alternative ways of accomplishing the same thing, what were they? The purpose of this section is to motivate why the design is the way it is and not some other way.