morningman commented on code in PR #69831:
URL: https://github.com/apache/doris/pull/69831#discussion_r4423900660


##########
fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStream.java:
##########
@@ -43,30 +44,28 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ConcurrentMap;
 import java.util.function.Predicate;
 
 public class OlapTableStream extends BaseTableStream {
 
-    @SerializedName("po")
-    private Map<Long, Long> partitionOffset;
+    // partition id -> what the stream holds for the partition. A state is 
replaced as a whole under the stream write
+    // lock, never changed in place, and it is also read without the lock: an 
IVM refresh reads the stream through a
+    // scan it creates after the statement has locked its tables 
(OlapTableStreamWrapper). So it is a concurrent map,
+    // and such a read sees one whole state of a partition.

Review Comment:
   The format change is intended. The PR description says so under Persistence: 
"Table Stream is not released, so the old fields are not read: a stream from an 
image written by an earlier build reads back with no partition state and has to 
be dropped and recreated."
   
   No release carries Table Stream: `OlapTableStream` is on none of branch-4.0, 
branch-4.1 and branch-4.2, and `enable_table_stream` is experimental, off by 
default and not mutable. So there is no rolling upgrade from a release to 
cover. A cluster that ran an earlier master build with the switch on drops and 
recreates its streams and IVM MVs; we do not carry a migration for a format 
that was never released.
   
   The consequence you describe is right, and it is why such a stream has to be 
recreated rather than kept. In shared-nothing mode, where the offsets live only 
in FE metadata, an IVM refresh takes a partition without an offset for one the 
MV has not read yet: it reads the partition from the base table and appends it, 
so the MV gets those rows a second time and the refresh still succeeds. A user 
stream reads its retained changelog again.
   



##########
fe/fe-core/src/main/java/org/apache/doris/catalog/stream/TableStreamManager.java:
##########
@@ -281,16 +288,364 @@ private Optional<EditLogItem> 
cleanupStalePartitionOffsets(OlapTableStream strea
         return Optional.of(editLogItem);
     }
 
+    /**
+     * Records a keep-alive TSO for the partitions of an IVM stream whose 
offsets stay where the partitions were
+     * last consumed while the partitions are not written, so that a read of 
such a partition does not take its
+     * offset for expired once the partition is written again (F-2). A 
partition qualifies when the base table
+     * applies changelog.ttl_seconds, the stream has a real offset for it past 
the initial snapshot, nothing is
+     * committed to the partition after that offset, and the newer of the 
offset and its keep-alive TSO is older
+     * than half the TTL.
+     *
+     * <p>The keep-alive TSO is taken from the TSO service while the base 
table read lock is held. A commit takes
+     * its TSO inside the base table write lock, so every commit the partition 
has not had yet gets a larger one,
+     * and the partition holds no change in (offset, keep-alive TSO]. Locks 
are taken in the order
+     * {@link #cleanupStalePartitionOffsets(OlapTableStream)} takes them.
+     */
+    public Optional<EditLogItem> keepAliveIdlePartitions(OlapTableStream 
stream) {
+        if (!stream.tryReadLock(Table.TRY_LOCK_TIMEOUT_MS, 
TimeUnit.MILLISECONDS)) {
+            if (LOG.isDebugEnabled()) {
+                LOG.debug("skip keeping stream {} alive because stream read 
lock is busy", stream.getName());
+            }
+            return Optional.empty();
+        }
+        // stream read lock is held
+        OlapTable baseTable;
+        Map<Long, Long> offsets;
+        Map<Long, Long> effectiveOffsets = new HashMap<>();
+        try {
+            if (stream.isDisabled() || stream.isStale()) {
+                return Optional.empty();
+            }
+            baseTable = stream.getBaseTableNullable();
+            if (baseTable == null) {
+                return Optional.empty();
+            }
+            offsets = stream.getConsumedPartitionOffsets();
+            offsets.forEach((partitionId, offset) -> 
effectiveOffsets.put(partitionId,
+                    OlapTableStream.effectiveOffset(offset, 
stream.getKeepAlive(partitionId))));
+        } finally {
+            stream.readUnlock();
+        }
+        // stream read lock is released
+        if (offsets.isEmpty()) {
+            return Optional.empty();
+        }
+        while (DebugPointUtil.getDebugParamOrDefault(
+                
"TableStreamManager.keepAliveIdlePartitions.blockBeforeBaseTableLock", -1L) == 
stream.getId()) {
+            LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
+        }
+        if (!baseTable.tryReadLock(Table.TRY_LOCK_TIMEOUT_MS, 
TimeUnit.MILLISECONDS)) {
+            if (LOG.isDebugEnabled()) {
+                LOG.debug("skip keeping stream {} alive because base table {} 
read lock is busy",
+                        stream.getName(), baseTable.getName());
+            }
+            return Optional.empty();
+        }
+        Map<Long, Long> keptAlive = new HashMap<>();
+        long keepAliveTso;
+        EditLogItem editLogItem;
+        try {
+            if (baseTable.isDropped || !baseTable.hasChangelogTtl()) {
+                return Optional.empty();
+            }
+            long renewCutoffTso = 
keepAliveRenewCutoffTso(baseTable.getChangelogConfig().getTtlSeconds());
+            Set<Long> idle = new HashSet<>();
+            for (Map.Entry<Long, Long> entry : offsets.entrySet()) {
+                Partition partition = baseTable.getPartition(entry.getKey());
+                // A commit after the offset shows in the partition TSO once 
it is visible, and in the next
+                // version from the moment it is committed.
+                if (partition != null && effectiveOffsets.get(entry.getKey()) 
<= renewCutoffTso
+                        && partition.getTso() <= entry.getValue()
+                        && partition.getNextVersion() == 
partition.getVisibleVersion() + 1) {
+                    idle.add(entry.getKey());
+                }
+            }
+            if (idle.isEmpty()) {
+                return Optional.empty();
+            }
+            keepAliveTso = Env.getCurrentEnv().getTSOService().getTSO();
+            if (!stream.tryWriteLockIfExist(Table.TRY_LOCK_TIMEOUT_MS, 
TimeUnit.MILLISECONDS)) {
+                if (LOG.isDebugEnabled()) {
+                    LOG.debug("skip keeping stream {} alive because it is busy 
or dropped", stream.getName());
+                }
+                return Optional.empty();
+            }
+            try {
+                if (stream.isDisabled() || stream.isStale()) {
+                    return Optional.empty();
+                }
+                for (Long partitionId : idle) {
+                    // A consumption between the two stream locks moved the 
offset, and the partition waits for
+                    // the next round.
+                    if 
(offsets.get(partitionId).equals(stream.getPartitionOffset(partitionId))) {
+                        keptAlive.put(partitionId, offsets.get(partitionId));
+                    }
+                }
+                if (keptAlive.isEmpty()) {
+                    return Optional.empty();
+                }
+                stream.unprotectedKeepAlive(keptAlive, keepAliveTso);
+                editLogItem = 
Env.getCurrentEnv().getEditLog().logTableStreamCleanup(
+                        TableStreamCleanupInfo.ofKeepAlive(new 
TableStreamCleanupInfo.PartitionKeepAliveEntry(
+                                stream.getDatabase().getId(), stream.getId(), 
keptAlive, keepAliveTso)));
+            } finally {
+                stream.writeUnlock();
+            }
+        } finally {
+            baseTable.readUnlock();
+        }
+        LOG.info("kept {} idle partition offsets of stream {}.{} ({}) alive up 
to tso {}",
+                keptAlive.size(), stream.getDatabase().getFullName(), 
stream.getName(), stream.getId(),
+                keepAliveTso);
+        return Optional.of(editLogItem);
+    }
+
+    // Renewed once less than half the TTL is left: the newer of the offset 
and the keep-alive TSO would have
+    // expired under half the TTL. A TTL longer than the clock has run never 
expires anything, and this cutoff is
+    // 0 then.
+    private static long keepAliveRenewCutoffTso(long ttlSeconds) {
+        return 
TSOTimestamp.calculateCutoff(TSOTimestamp.composePhysicalTimestamp(System.currentTimeMillis()),
+                ttlSeconds / 2);
+    }
+
+    /**
+     * The cloud mode counterpart of {@link 
#keepAliveIdlePartitions(OlapTableStream)}, for the IVM streams of one
+     * round, whose offsets live in MetaService and are read from it in 
batches.
+     *
+     * <p>The keep-alive TSO is the committed TSO watermark, and MetaService 
is fenced at it before any of the
+     * offsets is read. Every commit at or below the watermark is unregistered 
from the TSO service by then, which
+     * does not mean it is over: one the FE gave up on after a response it may 
outlive, such as a MetaService
+     * transaction that timed out once its commit was sent, can still land. 
The fence settles every such commit --
+     * it has landed before the fence, and a read made afterwards shows it, or 
it can no longer land -- and a commit
+     * that lands later gets a larger TSO. A partition whose offset is at its 
end in that read thus holds no change
+     * in (offset, watermark], for good. A partition qualifies on what 
shared-nothing mode checks, as MetaService
+     * reports it -- a real offset past the initial snapshot, at the end of 
the partition, whose newer of it and its
+     * keep-alive TSO is older than half the TTL -- when the watermark is past 
that newer one. The keep-alive TSO is
+     * recorded in FE metadata with the offset it was taken against, as in 
shared-nothing mode, and counts only for
+     * a read from that offset: an offset MetaService no longer reports, after 
a consumption or a restore, leaves it
+     * out.
+     */
+    public List<EditLogItem> 
keepAliveIdleCloudPartitions(List<OlapTableStream> streams) {
+        return keepAliveIdleCloudPartitions(streams, 
CLOUD_KEEP_ALIVE_BATCH_PARTITIONS);
+    }
+
+    @VisibleForTesting
+    List<EditLogItem> keepAliveIdleCloudPartitions(List<OlapTableStream> 
streams, int batchPartitions) {
+        List<CloudKeepAliveSnapshot> snapshots = new ArrayList<>();
+        for (OlapTableStream stream : streams) {
+            snapshotForCloudKeepAlive(stream).ifPresent(snapshots::add);
+        }
+        if (snapshots.isEmpty()) {
+            return Collections.emptyList();
+        }
+        TSOService.TSOStatusSnapshot tsoStatus = 
Env.getCurrentEnv().getTSOService().getStatusSnapshot();
+        if (!tsoStatus.isInitialized() || tsoStatus.getCommittedTso() <= 0) {
+            LOG.info("skip keeping IVM stream offsets alive because the 
committed TSO is not available");
+            return Collections.emptyList();
+        }
+        long keepAliveTso = tsoStatus.getCommittedTso();
+        // One fence a round, before any of its reads. The commits MetaService 
is applying at that moment conflict
+        // on it once and are retried, as on any commit conflict.
+        long fenceTso;
+        try {
+            fenceTso = 
Env.getCurrentEnv().getGlobalTransactionMgr().advanceTsoFence(keepAliveTso);

Review Comment:
   The mechanism is right: `lastRoundMs` is set before the round, the three 
paths you list record nothing (the first two for the whole round, a failed 
`getReadStates` for its batch), and the next attempt comes one 
`ivm_stream_keepalive_interval_second` later. Shared-nothing mode has the same 
shape (a `getTSO()` failure, a busy lock).
   
   Leaving a failed round to the next one is intended. Such a round records 
nothing (`testCloudKeepAliveSkipsRoundWhenTheFenceFails`, 
`testCloudKeepAliveSkipsRoundWhenReadStatesFail`), and the next round that 
succeeds renews a partition that is still idle even if its K has expired by 
then: neither mode's condition asks whether K is within the TTL, and "nothing 
was committed after the offset" holds however old K is. What a failed round can 
cost is the refresh that reads a write made after it, which falls back to 
COMPLETE as before this PR (a strict INCREMENTAL refresh fails); no row is read 
wrong.
   
   How much that costs is a matter of margin. When an idle partition is 
written, the newer of its offset and K is at most TTL/2 + one interval old, and 
one interval older for each round that could not renew it. After the write the 
partition is not renewed any more, and the refresh that reads the write has to 
start before that TSO is a TTL old. So that refresh is incremental if it starts 
within TTL/2 - interval after the write, one interval less per failed round. 
With the defaults (a TTL of a day, 600 seconds) that is 11 h 50 min, and a 
refresh right after the write still reads incrementally after 71 failed rounds 
in a row.
   
   What this comment does show is that the bound in the config description was 
wrong: "at most half the shortest changelog.ttl_seconds". At half, no time is 
left even when every round succeeds, and a refresh shortly after the write 
falls back; retrying failed rounds sooner would not change that. Fixed in 
00befb02e5e: the description of `ivm_stream_keepalive_interval_second` now 
states the time a refresh has after a write, less one interval for each check 
that could not renew the partition, and asks for an interval well under half 
the TTL. The PR description says the same.
   
   A faster retry is not added. A cloud round advances MetaService's TSO fence 
before it reads, so retrying sooner while MetaService is failing writes to it 
more often, and the commits being applied at that moment conflict on each fence 
once. The interval is the retry period; more margin comes from a shorter 
interval, which costs more checks but no more edit logs.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to