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.

Document the state by adding a label to the FIP page with one of "discussion", "accepted", "released", "rejected".

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

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 / ReleaseKvSnapshotLeaseResponse  

3. DropKvSnapshotLeaseRequest / DropKvSnapshotLeaseResponse   

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.

  1. coordinator_kvSnapshotLeaseCount : the current number of active kv snapshot leases.
  2. 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?

  1. When starting a Flink job
    In initNonPartitionedSplits and initPrimaryKeyTablePartitionSplits, after obtaining the kvSnapshots, it is necessary to call acquireKvSnapshotLease to complete the registration of the KV snapshot lease.

  2. When restoring a Flink job
    In initNonPartitionedSplits and initPrimaryKeyTablePartitionSplits, after obtaining the kvSnapshots, first determine which ones should be ignored. For the buckets that are not ignored, acquireKvSnapshotLease must be called to complete the registration of the KV snapshot lease.

  3. When a new partition is added
    In initPrimaryKeyTablePartitionSplits, after obtaining the kvSnapshotsacquireKvSnapshotLease must be called to register the KV snapshot lease for the current partition.

  4. Note: Since Admin#getLatestKvSnapshots(TablePath tablePath) and Admin#acquireKvSnapshotLease are not atomic operations, the latest kv snapshot obtained via get may become outdated by the time it is passed to acquire. 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: the failedTableBucketSet in the AcquireKvSnapshotLeaseResult returned by acquireKvSnapshotLease must 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

  1. First, the default value of kv.snapshot.num-retained will be set to 2 , and by default, only two snapshot will be retained afterward.
  2. 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 KvSnapshotLeaseManager to 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.


  • No labels