This is an automated email from the ASF dual-hosted git repository.

bbejeck pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new d2328ff720b KAFKA-20495: Add transactional support to in-memory window 
and session stores (#22652)
d2328ff720b is described below

commit d2328ff720ba4337d50bbdea01342fb4141dcd3f
Author: Nick Telford <[email protected]>
AuthorDate: Thu Jun 25 22:06:51 2026 +0100

    KAFKA-20495: Add transactional support to in-memory window and session 
stores (#22652)
    
    In-memory window and session stores need the same transactional,
    isolation-aware behaviour KIP-892 introduced for the key-value store:
    when transactional state stores are enabled (EOS), writes must be staged
    in a transaction buffer and only applied to the store on commit, and
    interactive queries must be able to read at `READ_COMMITTED` (ignoring
    staged writes) or `READ_UNCOMMITTED`.
    
    This adds `InMemoryWindowTransactionBuffer` and
    `InMemorySessionTransactionBuffer`, each extending
    `AbstractTransactionBuffer` with a composite key that encodes the
    store's ordering — timestamp/key for windows, endTime/key/startTime for
    sessions. `InMemoryWindowStore` and `InMemorySessionStore` route reads
    and writes through their buffer when transactional state stores are
    enabled, and report their staged size via
    `approximateNumUncommittedBytes()`.
    
    Each buffer implements `newBaseSnapshotIterator` so non-owner
    (interactive-query) reads get a point-in-time view: the bounded range of
    the backing segment map is eagerly deep-copied under the snapshot read
    lock, isolating the returned iterator from concurrent owner mutation.
    
    Reviewers: Bill Bejeck <[email protected]>
---
 .../state/internals/InMemorySessionStore.java      | 262 ++++++++++++-
 .../InMemorySessionTransactionBuffer.java          | 278 ++++++++++++++
 .../state/internals/InMemoryWindowStore.java       | 405 ++++++++++++++++++++-
 .../internals/InMemoryWindowTransactionBuffer.java | 203 +++++++++++
 .../internals/AbstractSessionBytesStoreTest.java   |  12 +-
 .../internals/AbstractWindowBytesStoreTest.java    |  14 +-
 .../InMemoryTransactionalSessionStoreTest.java     |  77 ++++
 .../InMemoryTransactionalWindowStoreTest.java      | 125 +++++++
 8 files changed, 1342 insertions(+), 34 deletions(-)

diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionStore.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionStore.java
index dd3f083aa10..b44389772bc 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionStore.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionStore.java
@@ -47,10 +47,12 @@ import java.util.Map;
 import java.util.Map.Entry;
 import java.util.NoSuchElementException;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentNavigableMap;
 import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.function.Consumer;
 
 import static 
org.apache.kafka.streams.StreamsConfig.InternalConfig.IQ_CONSISTENCY_OFFSET_VECTOR_ENABLED;
 
@@ -74,11 +76,13 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
 
     private final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> endTimeMap = new 
ConcurrentSkipListMap<>();
     private final Set<InMemorySessionStoreIterator> openIterators  = 
ConcurrentHashMap.newKeySet();
+    private final Set<TransactionalSessionIterator> openTransactionalIterators 
= ConcurrentHashMap.newKeySet();
 
     private volatile boolean open = false;
 
     private StateStoreContext stateStoreContext;
     private final Position position;
+    private InMemorySessionTransactionBuffer transactionBuffer;
 
     public InMemorySessionStore(
         final String name,
@@ -133,19 +137,43 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
                 root,
                 (RecordBatchingStateRestoreCallback) records -> {
                     synchronized (position) {
+                        long expiredRecords = 0;
                         for (final ConsumerRecord<byte[], byte[]> record : 
records) {
-                            
put(SessionKeySchema.from(Bytes.wrap(record.key())), record.value());
+                            final Windowed<Bytes> sessionKey = 
SessionKeySchema.from(Bytes.wrap(record.key()));
+                            final long windowEndTimestamp = 
sessionKey.window().end();
+                            observedStreamTime = Math.max(observedStreamTime, 
windowEndTimestamp);
+                            if (windowEndTimestamp <= observedStreamTime - 
retentionPeriod) {
+                                expiredRecords++;
+                            } else {
+                                // Write directly to the committed map: 
restored records are already committed.
+                                putInternal(sessionKey, record.value());
+                            }
                             
ChangelogRecordDeserializationHelper.applyChecksAndUpdatePosition(
                                 record,
                                 consistencyEnabled,
                                 position
                             );
                         }
+                        removeExpiredSegments();
+                        if (expiredRecords > 0) {
+                            if (expiredRecordSensor != null && context != 
null) {
+                                expiredRecordSensor.record(expiredRecords, 
context.currentSystemTimeMs());
+                            }
+                            LOG.warn("Skipping {} records for expired 
segments.", expiredRecords);
+                        }
                     }
                 }
             );
         }
         open = true;
+
+        final boolean transactional = StreamsConfig.InternalConfig.getBoolean(
+            stateStoreContext.appConfigs(),
+            StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG,
+            false);
+        if (transactional) {
+            this.transactionBuffer = new 
InMemorySessionTransactionBuffer(endTimeMap);
+        }
     }
 
     @Override
@@ -168,23 +196,38 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
                     expiredRecordSensor.record(1.0d, 
context.currentSystemTimeMs());
                 }
                 LOG.warn("Skipping record for expired segment.");
+            } else if (transactionBuffer != null) {
+                transactionBuffer.stage(sessionKey, aggregate);
             } else {
-                if (aggregate != null) {
-                    endTimeMap.computeIfAbsent(windowEndTimestamp, t -> new 
ConcurrentSkipListMap<>());
-                    final ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>> keyMap = 
endTimeMap.get(windowEndTimestamp);
-                    keyMap.computeIfAbsent(sessionKey.key(), t -> new 
ConcurrentSkipListMap<>());
-                    
keyMap.get(sessionKey.key()).put(sessionKey.window().start(), aggregate);
-                } else {
-                    remove(sessionKey);
-                }
+                putInternal(sessionKey, aggregate);
             }
 
             StoreQueryUtils.updatePosition(position, stateStoreContext);
         }
     }
 
+    private void putInternal(final Windowed<Bytes> sessionKey, final byte[] 
aggregate) {
+        if (aggregate != null) {
+            final long windowEndTimestamp = sessionKey.window().end();
+            endTimeMap.computeIfAbsent(windowEndTimestamp, t -> new 
ConcurrentSkipListMap<>());
+            final ConcurrentNavigableMap<Bytes, ConcurrentNavigableMap<Long, 
byte[]>> keyMap = endTimeMap.get(windowEndTimestamp);
+            keyMap.computeIfAbsent(sessionKey.key(), t -> new 
ConcurrentSkipListMap<>());
+            keyMap.get(sessionKey.key()).put(sessionKey.window().start(), 
aggregate);
+        } else {
+            removeFromBase(sessionKey);
+        }
+    }
+
     @Override
     public void remove(final Windowed<Bytes> sessionKey) {
+        if (transactionBuffer != null) {
+            transactionBuffer.stage(sessionKey, null);
+            return;
+        }
+        removeFromBase(sessionKey);
+    }
+
+    private void removeFromBase(final Windowed<Bytes> sessionKey) {
         final ConcurrentNavigableMap<Bytes, ConcurrentNavigableMap<Long, 
byte[]>> keyMap = endTimeMap.get(sessionKey.window().end());
         if (keyMap == null) {
             return;
@@ -215,6 +258,13 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
 
         // Only need to search if the record hasn't expired yet
         if (sessionEndTime > observedStreamTime - retentionPeriod) {
+            if (transactionBuffer != null) {
+                final Optional<byte[]> staged = transactionBuffer.get(key, 
sessionStartTime, sessionEndTime);
+                if (staged != null) {
+                    return staged.orElse(null);
+                }
+            }
+
             final ConcurrentNavigableMap<Bytes, ConcurrentNavigableMap<Long, 
byte[]>> keyMap = endTimeMap.get(sessionEndTime);
             if (keyMap != null) {
                 final ConcurrentNavigableMap<Long, byte[]> startTimeMap = 
keyMap.get(key);
@@ -231,6 +281,12 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
                                                                   final long 
latestSessionEndTime) {
         removeExpiredSegments();
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(
+                null, null, Long.MAX_VALUE, earliestSessionEndTime, 
latestSessionEndTime, true
+            );
+        }
+
         final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> endTimSubMap
             = endTimeMap.subMap(earliestSessionEndTime, true, 
latestSessionEndTime, true);
 
@@ -245,6 +301,12 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
 
         removeExpiredSegments();
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(
+                key, key, latestSessionStartTime, earliestSessionEndTime, 
Long.MAX_VALUE, true
+            );
+        }
+
         return registerNewIterator(key,
                                    key,
                                    latestSessionStartTime,
@@ -260,6 +322,12 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
 
         removeExpiredSegments();
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(
+                key, key, latestSessionStartTime, earliestSessionEndTime, 
Long.MAX_VALUE, false
+            );
+        }
+
         return registerNewIterator(
             key,
             key,
@@ -281,6 +349,12 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
             return KeyValueIterators.emptyIterator();
         }
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(
+                keyFrom, keyTo, latestSessionStartTime, 
earliestSessionEndTime, Long.MAX_VALUE, true
+            );
+        }
+
         return registerNewIterator(keyFrom,
                                    keyTo,
                                    latestSessionStartTime,
@@ -300,6 +374,12 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
             return KeyValueIterators.emptyIterator();
         }
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(
+                keyFrom, keyTo, latestSessionStartTime, 
earliestSessionEndTime, Long.MAX_VALUE, false
+            );
+        }
+
         return registerNewIterator(
             keyFrom,
             keyTo,
@@ -316,6 +396,10 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
 
         removeExpiredSegments();
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(key, key, Long.MAX_VALUE, 
0, Long.MAX_VALUE, true);
+        }
+
         return registerNewIterator(key, key, Long.MAX_VALUE, 
endTimeMap.entrySet().iterator(), true);
     }
 
@@ -326,6 +410,10 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
 
         removeExpiredSegments();
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(key, key, Long.MAX_VALUE, 
0, Long.MAX_VALUE, false);
+        }
+
         return registerNewIterator(key, key, Long.MAX_VALUE, 
endTimeMap.descendingMap().entrySet().iterator(), false);
     }
 
@@ -333,6 +421,10 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
     public KeyValueIterator<Windowed<Bytes>, byte[]> fetch(final Bytes 
keyFrom, final Bytes keyTo) {
         removeExpiredSegments();
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(keyFrom, keyTo, 
Long.MAX_VALUE, 0, Long.MAX_VALUE, true);
+        }
+
         return registerNewIterator(keyFrom, keyTo, Long.MAX_VALUE, 
endTimeMap.entrySet().iterator(), true);
     }
 
@@ -340,6 +432,10 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
     public KeyValueIterator<Windowed<Bytes>, byte[]> backwardFetch(final Bytes 
keyFrom, final Bytes keyTo) {
         removeExpiredSegments();
 
+        if (transactionBuffer != null) {
+            return newTransactionalSessionIterator(keyFrom, keyTo, 
Long.MAX_VALUE, 0, Long.MAX_VALUE, false);
+        }
+
         return registerNewIterator(
             keyFrom, keyTo, Long.MAX_VALUE, 
endTimeMap.descendingMap().entrySet().iterator(), false);
     }
@@ -369,22 +465,41 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
         );
     }
 
+    @Override
+    public long approximateNumUncommittedBytes() {
+        if (transactionBuffer != null) {
+            return transactionBuffer.approximateNumUncommittedBytes();
+        }
+        return 0;
+    }
+
     @Override
     public void commit(final Map<TopicPartition, Long> changelogOffsets) {
-        // do-nothing since it is in-memory
+        if (transactionBuffer != null) {
+            transactionBuffer.commit();
+        }
     }
 
     @Override
     public void close() {
-        if (openIterators.size() != 0) {
-            LOG.warn("Closing {} open iterators for store {}", 
openIterators.size(), name);
+        if (transactionBuffer != null) {
+            transactionBuffer.rollback();
+        }
+
+        final int openCount = openIterators.size() + 
openTransactionalIterators.size();
+        if (openCount != 0) {
+            LOG.warn("Closing {} open iterators for store {}", openCount, 
name);
             for (final InMemorySessionStoreIterator it : openIterators) {
                 it.close();
             }
+            for (final TransactionalSessionIterator it : 
openTransactionalIterators) {
+                it.close();
+            }
         }
 
         endTimeMap.clear();
         openIterators.clear();
+        openTransactionalIterators.clear();
         open = false;
     }
 
@@ -405,6 +520,22 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
         endTimeMap.headMap(minLiveTime, false).clear();
     }
 
+    private KeyValueIterator<Windowed<Bytes>, byte[]> 
newTransactionalSessionIterator(
+            final Bytes keyFrom,
+            final Bytes keyTo,
+            final long latestSessionStartTime,
+            final long earliestSessionEndTime,
+            final long latestSessionEndTime,
+            final boolean forward) {
+        final TransactionalSessionIterator iterator = new 
TransactionalSessionIterator(
+            transactionBuffer, keyFrom, keyTo, latestSessionStartTime,
+            earliestSessionEndTime, latestSessionEndTime,
+            observedStreamTime - retentionPeriod, forward, 
openTransactionalIterators::remove
+        );
+        openTransactionalIterators.add(iterator);
+        return iterator;
+    }
+
     private InMemorySessionStoreIterator registerNewIterator(final Bytes 
keyFrom,
                                                              final Bytes keyTo,
                                                              final long 
latestSessionStartTime,
@@ -423,11 +554,116 @@ public class InMemorySessionStore implements 
SessionStore<Bytes, byte[]>, WithRe
         return iterator;
     }
 
+    /**
+     * A session iterator backed by a transactional buffer's merge scan.
+     * Converts SessionEntryKey/byte[] pairs into Windowed<Bytes>/byte[] pairs,
+     * filtering by latestSessionStartTime.
+     */
+    private static class TransactionalSessionIterator implements 
KeyValueIterator<Windowed<Bytes>, byte[]> {
+        private final 
KeyValueIterator<InMemorySessionTransactionBuffer.SessionEntryKey, byte[]> 
delegate;
+        private final Bytes keyFrom;
+        private final Bytes keyTo;
+        private final long latestSessionStartTime;
+        private final long oldestRetainedEndTime;
+        private final Consumer<TransactionalSessionIterator> deregister;
+        private KeyValue<Windowed<Bytes>, byte[]> prefetched;
+        private boolean closed = false;
+
+        TransactionalSessionIterator(
+                final InMemorySessionTransactionBuffer buffer,
+                final Bytes keyFrom,
+                final Bytes keyTo,
+                final long latestSessionStartTime,
+                final long earliestSessionEndTime,
+                final long latestSessionEndTime,
+                final long oldestRetainedEndTime,
+                final boolean forward,
+                final Consumer<TransactionalSessionIterator> deregister) {
+            this.keyFrom = keyFrom;
+            this.keyTo = keyTo;
+            this.latestSessionStartTime = latestSessionStartTime;
+            this.oldestRetainedEndTime = oldestRetainedEndTime;
+            this.deregister = deregister;
+
+            // startTime sorts descending, so the lower bound carries the 
largest startTime and the
+            // upper bound the smallest. A null key leaves the key dimension 
open (see SessionEntryKey).
+            final InMemorySessionTransactionBuffer.SessionEntryKey from =
+                new 
InMemorySessionTransactionBuffer.SessionEntryKey(earliestSessionEndTime, 
keyFrom, Long.MAX_VALUE);
+            final InMemorySessionTransactionBuffer.SessionEntryKey to =
+                new 
InMemorySessionTransactionBuffer.SessionEntryKey(latestSessionEndTime, keyTo, 
0);
+
+            this.delegate = buffer.range(from, to, forward, true);
+        }
+
+        @Override
+        public Windowed<Bytes> peekNextKey() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            return prefetched.key;
+        }
+
+        @Override
+        public boolean hasNext() {
+            if (closed) {
+                return false;
+            }
+            if (prefetched != null) {
+                return true;
+            }
+            prefetched = computeNext();
+            return prefetched != null;
+        }
+
+        @Override
+        public KeyValue<Windowed<Bytes>, byte[]> next() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            final KeyValue<Windowed<Bytes>, byte[]> result = prefetched;
+            prefetched = null;
+            return result;
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+            prefetched = null;
+            try {
+                delegate.close();
+            } finally {
+                deregister.accept(this);
+            }
+        }
+
+        private KeyValue<Windowed<Bytes>, byte[]> computeNext() {
+            while (delegate.hasNext()) {
+                final 
KeyValue<InMemorySessionTransactionBuffer.SessionEntryKey, byte[]> entry = 
delegate.next();
+                // The committed side is already pruned by 
removeExpiredSegments, but the staged side is
+                // not; drop expired entries here. The staged scan is also 
bounded only by endTime, so
+                // filter the key range and latestSessionStartTime too.
+                if (entry.key.endTime() > oldestRetainedEndTime
+                    && entry.key.startTime() <= latestSessionStartTime
+                    && keyInRange(entry.key.key())) {
+                    final SessionWindow sessionWindow = new 
SessionWindow(entry.key.startTime(), entry.key.endTime());
+                    final Windowed<Bytes> windowedKey = new 
Windowed<>(entry.key.key(), sessionWindow);
+                    return new KeyValue<>(windowedKey, entry.value);
+                }
+            }
+            return null;
+        }
+
+        private boolean keyInRange(final Bytes key) {
+            return (keyFrom == null || key.compareTo(keyFrom) >= 0)
+                && (keyTo == null || key.compareTo(keyTo) <= 0);
+        }
+    }
+
     interface ClosingCallback {
         void deregisterIterator(final InMemorySessionStoreIterator iterator);
     }
 
-    private static class InMemorySessionStoreIterator implements 
KeyValueIterator<Windowed<Bytes>, byte[]> {
+    static class InMemorySessionStoreIterator implements 
KeyValueIterator<Windowed<Bytes>, byte[]> {
 
         private final Iterator<Entry<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>>> endTimeIterator;
         private Iterator<Entry<Bytes, ConcurrentNavigableMap<Long, byte[]>>> 
keyIterator;
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionTransactionBuffer.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionTransactionBuffer.java
new file mode 100644
index 00000000000..00e794a7fcb
--- /dev/null
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionTransactionBuffer.java
@@ -0,0 +1,278 @@
+/*
+ * 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.kafka.streams.state.internals;
+
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.kstream.Windowed;
+import org.apache.kafka.streams.state.KeyValueIterator;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentNavigableMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+
+/**
+ * A {@link TransactionBuffer} implementation for {@link InMemorySessionStore}.
+ * Uses a composite key of (endTime, key, startTime) to maintain correct sort 
order in the staging map.
+ */
+class InMemorySessionTransactionBuffer extends 
AbstractTransactionBuffer<InMemorySessionTransactionBuffer.SessionEntryKey> {
+
+    private final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> endTimeMap;
+
+    InMemorySessionTransactionBuffer(
+            final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> endTimeMap) {
+        this.endTimeMap = endTimeMap;
+    }
+
+    /**
+     * Composite key for the session store staging map. Sorts by endTime, then 
key, then startTime.
+     */
+    static final class SessionEntryKey implements Comparable<SessionEntryKey> {
+        private final long endTime;
+        private final Bytes key;
+        private final long startTime;
+
+        SessionEntryKey(final long endTime, final Bytes key, final long 
startTime) {
+            this.endTime = endTime;
+            this.key = key;
+            this.startTime = startTime;
+        }
+
+        long endTime() {
+            return endTime;
+        }
+
+        Bytes key() {
+            return key;
+        }
+
+        long startTime() {
+            return startTime;
+        }
+
+        @Override
+        public int compareTo(final SessionEntryKey other) {
+            int cmp = Long.compare(this.endTime, other.endTime);
+            if (cmp != 0) {
+                return cmp;
+            }
+            if (this.key != null && other.key != null) {
+                cmp = this.key.compareTo(other.key);
+                if (cmp != 0) {
+                    return cmp;
+                }
+            }
+            // Descending startTime, matching the order the reused 
InMemorySessionStoreIterator emits,
+            // so the staged map and the base iterator merge in lock-step.
+            return Long.compare(other.startTime, this.startTime);
+        }
+
+        @Override
+        public boolean equals(final Object o) {
+            if (this == o) return true;
+            if (!(o instanceof SessionEntryKey)) return false;
+            final SessionEntryKey that = (SessionEntryKey) o;
+            return endTime == that.endTime && startTime == that.startTime && 
Objects.equals(key, that.key);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(endTime, key, startTime);
+        }
+    }
+
+    // -- Convenience methods for the store --
+
+    void stage(final Windowed<Bytes> sessionKey, final byte[] value) {
+        super.stage(new SessionEntryKey(sessionKey.window().end(), 
sessionKey.key(), sessionKey.window().start()), value);
+    }
+
+    Optional<byte[]> get(final Bytes key, final long startTime, final long 
endTime) {
+        return super.get(new SessionEntryKey(endTime, key, startTime));
+    }
+
+    // -- AbstractTransactionBuffer implementation --
+
+    @Override
+    int estimateKeySize(final SessionEntryKey key) {
+        return 2 * Long.BYTES + key.key().get().length;
+    }
+
+    @Override
+    void stageToBackend(final SessionEntryKey key, final byte[] value) {
+        // no-op — staging map is sufficient; no write-batch concept for 
in-memory
+    }
+
+    @Override
+    ManagedKeyValueIterator<SessionEntryKey, byte[]> newBaseIterator(final 
SessionEntryKey from, final SessionEntryKey to) {
+        return newBaseIterator(from, to, true, true);
+    }
+
+    @Override
+    ManagedKeyValueIterator<SessionEntryKey, byte[]> newBaseIterator(final 
SessionEntryKey from, final SessionEntryKey to,
+                                                                     final 
boolean forward, final boolean toInclusive) {
+        final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> timeRange;
+        if (from != null && to != null) {
+            timeRange = endTimeMap.subMap(from.endTime(), true, to.endTime(), 
true);
+        } else if (from != null) {
+            timeRange = endTimeMap.tailMap(from.endTime(), true);
+        } else if (to != null) {
+            timeRange = endTimeMap.headMap(to.endTime(), true);
+        } else {
+            timeRange = endTimeMap;
+        }
+
+        return baseIterator(forward ? timeRange : timeRange.descendingMap(), 
from, to, forward);
+    }
+
+    /**
+     * Non-owner (IQ) path: eagerly deep-copies the bounded end-time range 
while the caller holds
+     * the snapshot read-lock, providing true point-in-time isolation. The 
returned iterator never
+     * touches the live end-time map, so concurrent owner mutation cannot 
disturb it.
+     */
+    @Override
+    ManagedKeyValueIterator<SessionEntryKey, byte[]> 
newBaseSnapshotIterator(final SessionEntryKey from, final SessionEntryKey to,
+                                                                             
final boolean forward, final boolean toInclusive) {
+        final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> timeRange;
+        if (from != null && to != null) {
+            timeRange = endTimeMap.subMap(from.endTime(), true, to.endTime(), 
true);
+        } else if (from != null) {
+            timeRange = endTimeMap.tailMap(from.endTime(), true);
+        } else if (to != null) {
+            timeRange = endTimeMap.headMap(to.endTime(), true);
+        } else {
+            timeRange = endTimeMap;
+        }
+
+        final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> copy = new ConcurrentSkipListMap<>();
+        for (final Map.Entry<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> endTimeEntry : timeRange.entrySet()) {
+            final ConcurrentNavigableMap<Bytes, ConcurrentNavigableMap<Long, 
byte[]>> keyCopy = new ConcurrentSkipListMap<>();
+            for (final Map.Entry<Bytes, ConcurrentNavigableMap<Long, byte[]>> 
keyEntry : endTimeEntry.getValue().entrySet()) {
+                keyCopy.put(keyEntry.getKey(), new 
ConcurrentSkipListMap<>(keyEntry.getValue()));
+            }
+            copy.put(endTimeEntry.getKey(), keyCopy);
+        }
+
+        return baseIterator(forward ? copy : copy.descendingMap(), from, to, 
forward);
+    }
+
+    /**
+     * Builds the committed-side base iterator by reusing the non-transactional
+     * {@link InMemorySessionStore.InMemorySessionStoreIterator}, which bounds 
keys at every endTime and
+     * emits in the same (endTime, key, descending-startTime) order as the 
staged map. The
+     * latestSessionStartTime bound is left open here (the store's {@code 
TransactionalSessionIterator}
+     * applies it); a null from/to key leaves the key dimension open.
+     */
+    private static ManagedKeyValueIterator<SessionEntryKey, byte[]> 
baseIterator(
+            final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>>> timeRange,
+            final SessionEntryKey from,
+            final SessionEntryKey to,
+            final boolean forward) {
+        return new SessionEntryKeyIterator(
+            new InMemorySessionStore.InMemorySessionStoreIterator(
+                from != null ? from.key() : null,
+                to != null ? to.key() : null,
+                Long.MAX_VALUE,
+                timeRange.entrySet().iterator(),
+                ignored -> { },
+                forward));
+    }
+
+    @Override
+    void flushToBase() {
+        for (final Map.Entry<SessionEntryKey, Optional<byte[]>> entry : 
pendingWrites.entrySet()) {
+            final long endTime = entry.getKey().endTime();
+            final Bytes key = entry.getKey().key();
+            final long startTime = entry.getKey().startTime();
+            if (entry.getValue().isPresent()) {
+                endTimeMap.computeIfAbsent(endTime, t -> new 
ConcurrentSkipListMap<>())
+                    .computeIfAbsent(key, k -> new ConcurrentSkipListMap<>())
+                    .put(startTime, entry.getValue().get());
+            } else {
+                final ConcurrentNavigableMap<Bytes, 
ConcurrentNavigableMap<Long, byte[]>> keyMap = endTimeMap.get(endTime);
+                if (keyMap != null) {
+                    final ConcurrentNavigableMap<Long, byte[]> startTimeMap = 
keyMap.get(key);
+                    if (startTimeMap != null) {
+                        startTimeMap.remove(startTime);
+                        if (startTimeMap.isEmpty()) {
+                            keyMap.remove(key);
+                            if (keyMap.isEmpty()) {
+                                endTimeMap.remove(endTime);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    @Override
+    void discardPendingBatch() {
+        // no-op — no backend batch to discard
+    }
+
+    /**
+     * Adapts the non-transactional session iterator's {@code Windowed<Bytes>} 
output to the
+     * {@link SessionEntryKey} space the staged-merge consumes.
+     */
+    private static final class SessionEntryKeyIterator implements 
ManagedKeyValueIterator<SessionEntryKey, byte[]> {
+        private final KeyValueIterator<Windowed<Bytes>, byte[]> delegate;
+        private Runnable closeCallback;
+
+        SessionEntryKeyIterator(final KeyValueIterator<Windowed<Bytes>, 
byte[]> delegate) {
+            this.delegate = delegate;
+        }
+
+        @Override
+        public boolean hasNext() {
+            return delegate.hasNext();
+        }
+
+        @Override
+        public KeyValue<SessionEntryKey, byte[]> next() {
+            final KeyValue<Windowed<Bytes>, byte[]> entry = delegate.next();
+            return new KeyValue<>(toKey(entry.key), entry.value);
+        }
+
+        @Override
+        public SessionEntryKey peekNextKey() {
+            return toKey(delegate.peekNextKey());
+        }
+
+        @Override
+        public void onClose(final Runnable closeCallback) {
+            this.closeCallback = closeCallback;
+        }
+
+        @Override
+        public void close() {
+            try {
+                delegate.close();
+            } finally {
+                if (closeCallback != null) {
+                    closeCallback.run();
+                }
+            }
+        }
+
+        private static SessionEntryKey toKey(final Windowed<Bytes> windowed) {
+            return new SessionEntryKey(windowed.window().end(), 
windowed.key(), windowed.window().start());
+        }
+    }
+}
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowStore.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowStore.java
index 6f5b1095f47..26f9634326b 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowStore.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowStore.java
@@ -49,10 +49,12 @@ import java.util.Iterator;
 import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentNavigableMap;
 import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.function.Consumer;
 
 import static 
org.apache.kafka.streams.StreamsConfig.InternalConfig.IQ_CONSISTENCY_OFFSET_VECTOR_ENABLED;
 import static 
org.apache.kafka.streams.state.internals.WindowKeySchema.extractStoreKeyBytes;
@@ -72,6 +74,7 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
 
     private final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
byte[]>> segmentMap = new ConcurrentSkipListMap<>();
     private final Set<InMemoryWindowStoreIteratorWrapper> openIterators = 
ConcurrentHashMap.newKeySet();
+    private final Set<KeyValueIterator<?, ?>> openTransactionalIterators = 
ConcurrentHashMap.newKeySet();
 
     private InternalProcessorContext<?, ?> internalProcessorContext;
     private Sensor expiredRecordSensor;
@@ -81,6 +84,7 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
     private volatile boolean open = false;
 
     private final Position position;
+    private InMemoryWindowTransactionBuffer transactionBuffer;
 
     public InMemoryWindowStore(final String name,
                                final long retentionPeriod,
@@ -129,23 +133,41 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
                 root,
                 (RecordBatchingStateRestoreCallback) records -> {
                     synchronized (position) {
+                        long expiredRecords = 0;
                         for (final ConsumerRecord<byte[], byte[]> record : 
records) {
-                            put(
-                                Bytes.wrap(extractStoreKeyBytes(record.key())),
-                                record.value(),
-                                extractStoreTimestamp(record.key())
-                            );
+                            final Bytes key = 
Bytes.wrap(extractStoreKeyBytes(record.key()));
+                            final long windowStartTimestamp = 
extractStoreTimestamp(record.key());
+                            observedStreamTime = Math.max(observedStreamTime, 
windowStartTimestamp);
+                            if (windowStartTimestamp <= observedStreamTime - 
retentionPeriod) {
+                                expiredRecords++;
+                            } else {
+                                // Write directly to the committed map: 
restored records are already committed.
+                                putInternal(key, record.value(), 
windowStartTimestamp);
+                            }
                             
ChangelogRecordDeserializationHelper.applyChecksAndUpdatePosition(
                                 record,
                                 consistencyEnabled,
                                 position
                             );
                         }
+                        removeExpiredSegments();
+                        if (expiredRecords > 0) {
+                            expiredRecordSensor.record(expiredRecords, 
internalProcessorContext.currentSystemTimeMs());
+                            LOG.warn("Skipping {} records for expired 
segments.", expiredRecords);
+                        }
                     }
                 }
             );
         }
         open = true;
+
+        final boolean transactional = StreamsConfig.InternalConfig.getBoolean(
+            stateStoreContext.appConfigs(),
+            StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG,
+            false);
+        if (transactional) {
+            this.transactionBuffer = new 
InMemoryWindowTransactionBuffer(segmentMap, retainDuplicates);
+        }
     }
 
     @Override
@@ -162,28 +184,41 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
             if (windowStartTimestamp <= observedStreamTime - retentionPeriod) {
                 expiredRecordSensor.record(1.0d, 
internalProcessorContext.currentSystemTimeMs());
                 LOG.warn("Skipping record for expired segment.");
-            } else {
+            } else if (transactionBuffer != null) {
                 if (value != null) {
                     maybeUpdateSeqnumForDups();
                     final Bytes keyBytes = retainDuplicates ? wrapForDups(key, 
seqnum) : key;
-                    segmentMap.computeIfAbsent(windowStartTimestamp, t -> new 
ConcurrentSkipListMap<>());
-                    segmentMap.get(windowStartTimestamp).put(keyBytes, value);
+                    transactionBuffer.stage(windowStartTimestamp, keyBytes, 
value);
                 } else if (!retainDuplicates) {
                     // Skip if value is null and duplicates are allowed since 
this delete is a no-op
-                    segmentMap.computeIfPresent(windowStartTimestamp, (t, 
kvMap) -> {
-                        kvMap.remove(key);
-                        if (kvMap.isEmpty()) {
-                            segmentMap.remove(windowStartTimestamp);
-                        }
-                        return kvMap;
-                    });
+                    transactionBuffer.stage(windowStartTimestamp, key, null);
                 }
+            } else {
+                putInternal(key, value, windowStartTimestamp);
             }
 
             StoreQueryUtils.updatePosition(position, internalProcessorContext);
         }
     }
 
+    private void putInternal(final Bytes key, final byte[] value, final long 
windowStartTimestamp) {
+        if (value != null) {
+            maybeUpdateSeqnumForDups();
+            final Bytes keyBytes = retainDuplicates ? wrapForDups(key, seqnum) 
: key;
+            segmentMap.computeIfAbsent(windowStartTimestamp, t -> new 
ConcurrentSkipListMap<>());
+            segmentMap.get(windowStartTimestamp).put(keyBytes, value);
+        } else if (!retainDuplicates) {
+            // Skip if value is null and duplicates are allowed since this 
delete is a no-op
+            segmentMap.computeIfPresent(windowStartTimestamp, (t, kvMap) -> {
+                kvMap.remove(key);
+                if (kvMap.isEmpty()) {
+                    segmentMap.remove(windowStartTimestamp);
+                }
+                return kvMap;
+            });
+        }
+    }
+
     @Override
     public byte[] fetch(final Bytes key, final long windowStartTimestamp) {
         Objects.requireNonNull(key, "key cannot be null");
@@ -194,6 +229,13 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
             return null;
         }
 
+        if (transactionBuffer != null) {
+            final Optional<byte[]> staged = 
transactionBuffer.get(windowStartTimestamp, key);
+            if (staged != null) {
+                return staged.orElse(null);
+            }
+        }
+
         final ConcurrentNavigableMap<Bytes, byte[]> kvMap = 
segmentMap.get(windowStartTimestamp);
         if (kvMap == null) {
             return null;
@@ -224,6 +266,15 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
             return WrappedInMemoryWindowStoreIterator.emptyIterator();
         }
 
+        if (transactionBuffer != null) {
+            final Bytes keyFrom = retainDuplicates ? wrapForDups(key, 0) : key;
+            final Bytes keyTo = retainDuplicates ? wrapForDups(key, 
Integer.MAX_VALUE) : key;
+            return registerTransactional(new TransactionalWindowStoreIterator(
+                transactionBuffer, keyFrom, keyTo, minTime, timeTo, forward, 
retainDuplicates,
+                openTransactionalIterators::remove
+            ));
+        }
+
         if (forward) {
             return registerNewWindowStoreIterator(
                 key,
@@ -279,6 +330,14 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
             return KeyValueIterators.emptyIterator();
         }
 
+        if (transactionBuffer != null) {
+            final Bytes keyFrom = (retainDuplicates && from != null) ? 
wrapForDups(from, 0) : from;
+            final Bytes keyTo = (retainDuplicates && to != null) ? 
wrapForDups(to, Integer.MAX_VALUE) : to;
+            return registerTransactional(new 
TransactionalWindowedKeyValueIterator(
+                transactionBuffer, keyFrom, keyTo, minTime, timeTo, forward, 
retainDuplicates, windowSize, openTransactionalIterators::remove
+            ));
+        }
+
         if (forward) {
             return registerNewWindowedKeyValueIterator(
                 from,
@@ -318,6 +377,12 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
             return KeyValueIterators.emptyIterator();
         }
 
+        if (transactionBuffer != null) {
+            return registerTransactional(new 
TransactionalWindowedKeyValueIterator(
+                transactionBuffer, null, null, minTime, timeTo, forward, 
retainDuplicates, windowSize, openTransactionalIterators::remove
+            ));
+        }
+
         if (forward) {
             return registerNewWindowedKeyValueIterator(
                 null,
@@ -343,6 +408,12 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
 
         final long minTime = observedStreamTime - retentionPeriod;
 
+        if (transactionBuffer != null) {
+            return registerTransactional(new 
TransactionalWindowedKeyValueIterator(
+                transactionBuffer, null, null, minTime + 1, Long.MAX_VALUE, 
true, retainDuplicates, windowSize, openTransactionalIterators::remove
+            ));
+        }
+
         return registerNewWindowedKeyValueIterator(
             null,
             null,
@@ -357,6 +428,12 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
 
         final long minTime = observedStreamTime - retentionPeriod;
 
+        if (transactionBuffer != null) {
+            return registerTransactional(new 
TransactionalWindowedKeyValueIterator(
+                transactionBuffer, null, null, minTime + 1, Long.MAX_VALUE, 
false, retainDuplicates, windowSize, openTransactionalIterators::remove
+            ));
+        }
+
         return registerNewWindowedKeyValueIterator(
             null,
             null,
@@ -390,24 +467,48 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
         );
     }
 
+    @Override
+    public long approximateNumUncommittedBytes() {
+        if (transactionBuffer != null) {
+            return transactionBuffer.approximateNumUncommittedBytes();
+        }
+        return 0;
+    }
+
     @Override
     public void commit(final Map<TopicPartition, Long> changelogOffsets) {
-        // do-nothing since it is in-memory
+        if (transactionBuffer != null) {
+            transactionBuffer.commit();
+        }
     }
 
     @Override
     public void close() {
-        if (openIterators.size() != 0) {
-            LOG.warn("Closing {} open iterators for store {}", 
openIterators.size(), name);
+        if (transactionBuffer != null) {
+            transactionBuffer.rollback();
+        }
+
+        final int openCount = openIterators.size() + 
openTransactionalIterators.size();
+        if (openCount != 0) {
+            LOG.warn("Closing {} open iterators for store {}", openCount, 
name);
             for (final InMemoryWindowStoreIteratorWrapper it : openIterators) {
                 it.close();
             }
+            for (final KeyValueIterator<?, ?> it : openTransactionalIterators) 
{
+                it.close();
+            }
         }
 
         segmentMap.clear();
+        openTransactionalIterators.clear();
         open = false;
     }
 
+    private <T extends KeyValueIterator<?, ?>> T registerTransactional(final T 
iterator) {
+        openTransactionalIterators.add(iterator);
+        return iterator;
+    }
+
     long numEntries() {
         return segmentMap.values().stream()
             .mapToLong(Map::size)
@@ -475,6 +576,219 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
         return iterator;
     }
 
+    private static Bytes lowerBoundKey(final Bytes keyFrom) {
+        // The staged composite scan needs a non-null lower bound; empty bytes 
is the natural minimum.
+        return keyFrom != null ? keyFrom : Bytes.wrap(new byte[0]);
+    }
+
+    private static Bytes unwrapBound(final Bytes wrappedBound, final boolean 
retainDuplicates) {
+        if (wrappedBound == null) {
+            return null;
+        }
+        return retainDuplicates ? getKey(wrappedBound) : wrappedBound;
+    }
+
+    /**
+     * Whether a stored (possibly seqnum-wrapped) key falls within the 
original key range. Needed
+     * because the staged composite scan is bounded only by timestamp (and, at 
the boundary
+     * timestamps, by wrapped-key order, which can admit out-of-range keys); 
the committed base
+     * iterator is already correctly bounded.
+     */
+    private static boolean keyInRange(final Bytes storedKey,
+                                      final Bytes unwrappedFrom,
+                                      final Bytes unwrappedTo,
+                                      final boolean retainDuplicates) {
+        final Bytes key = retainDuplicates ? getKey(storedKey) : storedKey;
+        return (unwrappedFrom == null || key.compareTo(unwrappedFrom) >= 0)
+            && (unwrappedTo == null || key.compareTo(unwrappedTo) <= 0);
+    }
+
+    /**
+     * A WindowStoreIterator over the transaction buffer's merge scan, 
exposing each entry's
+     * timestamp/value, filtered to the requested key.
+     */
+    private static class TransactionalWindowStoreIterator implements 
WindowStoreIterator<byte[]> {
+        private final 
KeyValueIterator<InMemoryWindowTransactionBuffer.WindowEntryKey, byte[]> 
delegate;
+        private final Bytes unwrappedFrom;
+        private final Bytes unwrappedTo;
+        private final boolean retainDuplicates;
+        private final Consumer<KeyValueIterator<?, ?>> deregister;
+        private KeyValue<Long, byte[]> prefetched;
+        private boolean closed = false;
+
+        TransactionalWindowStoreIterator(
+                final InMemoryWindowTransactionBuffer buffer,
+                final Bytes keyFrom,
+                final Bytes keyTo,
+                final long timeFrom,
+                final long timeTo,
+                final boolean forward,
+                final boolean retainDuplicates,
+                final Consumer<KeyValueIterator<?, ?>> deregister) {
+            this.retainDuplicates = retainDuplicates;
+            this.deregister = deregister;
+            this.unwrappedFrom = unwrapBound(keyFrom, retainDuplicates);
+            this.unwrappedTo = unwrapBound(keyTo, retainDuplicates);
+            final InMemoryWindowTransactionBuffer.WindowEntryKey from =
+                new InMemoryWindowTransactionBuffer.WindowEntryKey(timeFrom, 
lowerBoundKey(keyFrom));
+            final InMemoryWindowTransactionBuffer.WindowEntryKey to =
+                new InMemoryWindowTransactionBuffer.WindowEntryKey(timeTo, 
keyTo);
+
+            this.delegate = buffer.range(from, to, forward, true);
+        }
+
+        @Override
+        public Long peekNextKey() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            return prefetched.key;
+        }
+
+        @Override
+        public boolean hasNext() {
+            if (closed) {
+                return false;
+            }
+            if (prefetched != null) {
+                return true;
+            }
+            prefetched = computeNext();
+            return prefetched != null;
+        }
+
+        @Override
+        public KeyValue<Long, byte[]> next() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            final KeyValue<Long, byte[]> result = prefetched;
+            prefetched = null;
+            return result;
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+            prefetched = null;
+            try {
+                delegate.close();
+            } finally {
+                deregister.accept(this);
+            }
+        }
+
+        private KeyValue<Long, byte[]> computeNext() {
+            while (delegate.hasNext()) {
+                final KeyValue<InMemoryWindowTransactionBuffer.WindowEntryKey, 
byte[]> entry = delegate.next();
+                if (keyInRange(entry.key.key(), unwrappedFrom, unwrappedTo, 
retainDuplicates)) {
+                    return new KeyValue<>(entry.key.timestamp(), entry.value);
+                }
+            }
+            return null;
+        }
+    }
+
+    /**
+     * A Windowed KeyValueIterator over the transaction buffer's merge scan, 
filtered to the key
+     * range and with the stored (possibly seqnum-wrapped) key unwrapped.
+     */
+    private static class TransactionalWindowedKeyValueIterator implements 
KeyValueIterator<Windowed<Bytes>, byte[]> {
+        private final 
KeyValueIterator<InMemoryWindowTransactionBuffer.WindowEntryKey, byte[]> 
delegate;
+        private final Bytes unwrappedFrom;
+        private final Bytes unwrappedTo;
+        private final boolean retainDuplicates;
+        private final long windowSize;
+        private final Consumer<KeyValueIterator<?, ?>> deregister;
+        private KeyValue<Windowed<Bytes>, byte[]> prefetched;
+        private boolean closed = false;
+
+        TransactionalWindowedKeyValueIterator(
+                final InMemoryWindowTransactionBuffer buffer,
+                final Bytes keyFrom,
+                final Bytes keyTo,
+                final long timeFrom,
+                final long timeTo,
+                final boolean forward,
+                final boolean retainDuplicates,
+                final long windowSize,
+                final Consumer<KeyValueIterator<?, ?>> deregister) {
+            this.retainDuplicates = retainDuplicates;
+            this.windowSize = windowSize;
+            this.deregister = deregister;
+            this.unwrappedFrom = unwrapBound(keyFrom, retainDuplicates);
+            this.unwrappedTo = unwrapBound(keyTo, retainDuplicates);
+            final InMemoryWindowTransactionBuffer.WindowEntryKey from =
+                new InMemoryWindowTransactionBuffer.WindowEntryKey(timeFrom, 
lowerBoundKey(keyFrom));
+            final InMemoryWindowTransactionBuffer.WindowEntryKey to =
+                new InMemoryWindowTransactionBuffer.WindowEntryKey(timeTo, 
keyTo);
+
+            this.delegate = buffer.range(from, to, forward, true);
+        }
+
+        @Override
+        public Windowed<Bytes> peekNextKey() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            return prefetched.key;
+        }
+
+        @Override
+        public boolean hasNext() {
+            if (closed) {
+                return false;
+            }
+            if (prefetched != null) {
+                return true;
+            }
+            prefetched = computeNext();
+            return prefetched != null;
+        }
+
+        @Override
+        public KeyValue<Windowed<Bytes>, byte[]> next() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            final KeyValue<Windowed<Bytes>, byte[]> result = prefetched;
+            prefetched = null;
+            return result;
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+            prefetched = null;
+            try {
+                delegate.close();
+            } finally {
+                deregister.accept(this);
+            }
+        }
+
+        private KeyValue<Windowed<Bytes>, byte[]> computeNext() {
+            while (delegate.hasNext()) {
+                final KeyValue<InMemoryWindowTransactionBuffer.WindowEntryKey, 
byte[]> entry = delegate.next();
+                if (keyInRange(entry.key.key(), unwrappedFrom, unwrappedTo, 
retainDuplicates)) {
+                    return new KeyValue<>(toWindowed(entry.key), entry.value);
+                }
+            }
+            return null;
+        }
+
+        private Windowed<Bytes> toWindowed(final 
InMemoryWindowTransactionBuffer.WindowEntryKey entryKey) {
+            final Bytes key = retainDuplicates ? getKey(entryKey.key()) : 
entryKey.key();
+            long endTime = entryKey.timestamp() + windowSize;
+            if (endTime < 0) {
+                LOG.warn("Warning: window end time was truncated to Long.MAX");
+                endTime = Long.MAX_VALUE;
+            }
+            final TimeWindow timeWindow = new TimeWindow(entryKey.timestamp(), 
endTime);
+            return new Windowed<>(key, timeWindow);
+        }
+    }
+
 
     interface ClosingCallback {
         void deregisterIterator(final InMemoryWindowStoreIteratorWrapper 
iterator);
@@ -689,4 +1003,59 @@ public class InMemoryWindowStore implements 
WindowStore<Bytes, byte[]>, WithRete
             return new Windowed<>(key, timeWindow);
         }
     }
+
+    /**
+     * Yields each entry as an {@link 
InMemoryWindowTransactionBuffer.WindowEntryKey} keyed by the
+     * stored (possibly seqnum-wrapped) key. Reused by {@link 
InMemoryWindowTransactionBuffer} as the
+     * committed-side base iterator, so it merges in lock-step with the staged 
composite map; the
+     * store's transactional read wrappers unwrap the key afterwards.
+     */
+    static final class WindowEntryKeyIterator extends 
InMemoryWindowStoreIteratorWrapper
+        implements 
ManagedKeyValueIterator<InMemoryWindowTransactionBuffer.WindowEntryKey, byte[]> 
{
+
+        private Runnable closeCallback;
+
+        WindowEntryKeyIterator(final Bytes keyFrom,
+                               final Bytes keyTo,
+                               final Iterator<Map.Entry<Long, 
ConcurrentNavigableMap<Bytes, byte[]>>> segmentIterator,
+                               final boolean retainDuplicates,
+                               final boolean forward) {
+            super(keyFrom, keyTo, segmentIterator, ignored -> { }, 
retainDuplicates, forward);
+        }
+
+        @Override
+        public InMemoryWindowTransactionBuffer.WindowEntryKey peekNextKey() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            return new 
InMemoryWindowTransactionBuffer.WindowEntryKey(super.currentTime, 
super.next.key);
+        }
+
+        @Override
+        public KeyValue<InMemoryWindowTransactionBuffer.WindowEntryKey, 
byte[]> next() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            final KeyValue<InMemoryWindowTransactionBuffer.WindowEntryKey, 
byte[]> result =
+                new KeyValue<>(new 
InMemoryWindowTransactionBuffer.WindowEntryKey(super.currentTime, 
super.next.key), super.next.value);
+            super.next = null;
+            return result;
+        }
+
+        @Override
+        public void onClose(final Runnable closeCallback) {
+            this.closeCallback = closeCallback;
+        }
+
+        @Override
+        public void close() {
+            try {
+                super.close();
+            } finally {
+                if (closeCallback != null) {
+                    closeCallback.run();
+                }
+            }
+        }
+    }
 }
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowTransactionBuffer.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowTransactionBuffer.java
new file mode 100644
index 00000000000..1fd2ac9f753
--- /dev/null
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowTransactionBuffer.java
@@ -0,0 +1,203 @@
+/*
+ * 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.kafka.streams.state.internals;
+
+import org.apache.kafka.common.utils.Bytes;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentNavigableMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+
+/**
+ * A {@link TransactionBuffer} implementation for {@link InMemoryWindowStore}.
+ * Uses a composite key of (timestamp, key) to maintain correct sort order in 
the staging map.
+ */
+class InMemoryWindowTransactionBuffer extends 
AbstractTransactionBuffer<InMemoryWindowTransactionBuffer.WindowEntryKey> {
+
+    private final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
byte[]>> segmentMap;
+    private final boolean retainDuplicates;
+
+    InMemoryWindowTransactionBuffer(
+            final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
byte[]>> segmentMap,
+            final boolean retainDuplicates) {
+        this.segmentMap = segmentMap;
+        this.retainDuplicates = retainDuplicates;
+    }
+
+    /**
+     * Composite key for the window store staging map. Sorts by timestamp 
first, then by key.
+     */
+    static final class WindowEntryKey implements Comparable<WindowEntryKey> {
+        private final long timestamp;
+        private final Bytes key;
+
+        WindowEntryKey(final long timestamp, final Bytes key) {
+            this.timestamp = timestamp;
+            this.key = key;
+        }
+
+        long timestamp() {
+            return timestamp;
+        }
+
+        Bytes key() {
+            return key;
+        }
+
+        @Override
+        public int compareTo(final WindowEntryKey other) {
+            final int cmp = Long.compare(this.timestamp, other.timestamp);
+            if (cmp != 0) {
+                return cmp;
+            }
+            // A null key is an unbounded upper-bound marker used only in 
range scans; it sorts after
+            // every real key at the same timestamp. (The lower bound uses 
empty bytes, the natural minimum.)
+            if (this.key == null || other.key == null) {
+                return Boolean.compare(this.key == null, other.key == null);
+            }
+            return this.key.compareTo(other.key);
+        }
+
+        @Override
+        public boolean equals(final Object o) {
+            if (this == o) return true;
+            if (!(o instanceof WindowEntryKey)) return false;
+            final WindowEntryKey that = (WindowEntryKey) o;
+            return timestamp == that.timestamp && Objects.equals(key, 
that.key);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(timestamp, key);
+        }
+    }
+
+    // -- Convenience methods for the store --
+
+    void stage(final long timestamp, final Bytes key, final byte[] value) {
+        super.stage(new WindowEntryKey(timestamp, key), value);
+    }
+
+    Optional<byte[]> get(final long timestamp, final Bytes key) {
+        return super.get(new WindowEntryKey(timestamp, key));
+    }
+
+    // -- AbstractTransactionBuffer implementation --
+
+    @Override
+    int estimateKeySize(final WindowEntryKey key) {
+        return Long.BYTES + key.key().get().length;
+    }
+
+    @Override
+    void stageToBackend(final WindowEntryKey key, final byte[] value) {
+        // no-op — staging map is sufficient; no write-batch concept for 
in-memory
+    }
+
+    @Override
+    ManagedKeyValueIterator<WindowEntryKey, byte[]> newBaseIterator(final 
WindowEntryKey from, final WindowEntryKey to) {
+        return newBaseIterator(from, to, true, true);
+    }
+
+    @Override
+    ManagedKeyValueIterator<WindowEntryKey, byte[]> newBaseIterator(final 
WindowEntryKey from, final WindowEntryKey to,
+                                                                    final 
boolean forward, final boolean toInclusive) {
+        final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
byte[]>> timeRange;
+        if (from != null && to != null) {
+            timeRange = segmentMap.subMap(from.timestamp(), true, 
to.timestamp(), true);
+        } else if (from != null) {
+            timeRange = segmentMap.tailMap(from.timestamp(), true);
+        } else if (to != null) {
+            timeRange = segmentMap.headMap(to.timestamp(), true);
+        } else {
+            timeRange = segmentMap;
+        }
+
+        return baseIterator(forward ? timeRange : timeRange.descendingMap(), 
from, to, forward);
+    }
+
+    /**
+     * Non-owner (IQ) path: eagerly deep-copies the bounded time range while 
the caller holds the
+     * snapshot read-lock, providing true point-in-time isolation. The 
returned iterator never
+     * touches the live segment map, so concurrent owner mutation cannot 
disturb it.
+     */
+    @Override
+    ManagedKeyValueIterator<WindowEntryKey, byte[]> 
newBaseSnapshotIterator(final WindowEntryKey from, final WindowEntryKey to,
+                                                                            
final boolean forward, final boolean toInclusive) {
+        final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
byte[]>> timeRange;
+        if (from != null && to != null) {
+            timeRange = segmentMap.subMap(from.timestamp(), true, 
to.timestamp(), true);
+        } else if (from != null) {
+            timeRange = segmentMap.tailMap(from.timestamp(), true);
+        } else if (to != null) {
+            timeRange = segmentMap.headMap(to.timestamp(), true);
+        } else {
+            timeRange = segmentMap;
+        }
+
+        final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
byte[]>> copy = new ConcurrentSkipListMap<>();
+        for (final Map.Entry<Long, ConcurrentNavigableMap<Bytes, byte[]>> 
segment : timeRange.entrySet()) {
+            copy.put(segment.getKey(), new 
ConcurrentSkipListMap<>(segment.getValue()));
+        }
+
+        return baseIterator(forward ? copy : copy.descendingMap(), from, to, 
forward);
+    }
+
+    /**
+     * Builds the committed-side base iterator by reusing the 
non-transactional segment iterator
+     * ({@link InMemoryWindowStore.WindowEntryKeyIterator}), which bounds keys 
at every timestamp and
+     * emits in the same (timestamp, stored-key) order as the staged map. A 
null from/to key (and the
+     * empty-bytes lower-bound placeholder) leaves that side of the key 
dimension open.
+     */
+    private ManagedKeyValueIterator<WindowEntryKey, byte[]> baseIterator(
+            final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes, 
byte[]>> timeRange,
+            final WindowEntryKey from,
+            final WindowEntryKey to,
+            final boolean forward) {
+        final Bytes keyFrom = (from != null && from.key() != null && 
from.key().get().length > 0) ? from.key() : null;
+        final Bytes keyTo = (to != null) ? to.key() : null;
+        return new InMemoryWindowStore.WindowEntryKeyIterator(
+            keyFrom, keyTo, timeRange.entrySet().iterator(), retainDuplicates, 
forward);
+    }
+
+    @Override
+    void flushToBase() {
+        for (final Map.Entry<WindowEntryKey, Optional<byte[]>> entry : 
pendingWrites.entrySet()) {
+            final long ts = entry.getKey().timestamp();
+            final Bytes key = entry.getKey().key();
+            if (entry.getValue().isPresent()) {
+                segmentMap.computeIfAbsent(ts, t -> new 
ConcurrentSkipListMap<>()).put(key, entry.getValue().get());
+            } else {
+                final ConcurrentNavigableMap<Bytes, byte[]> kvMap = 
segmentMap.get(ts);
+                if (kvMap != null) {
+                    kvMap.remove(key);
+                    if (kvMap.isEmpty()) {
+                        segmentMap.remove(ts);
+                    }
+                }
+            }
+        }
+    }
+
+    @Override
+    void discardPendingBatch() {
+        // no-op — no backend batch to discard
+    }
+
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractSessionBytesStoreTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractSessionBytesStoreTest.java
index 39a1970f533..0bc97b29729 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractSessionBytesStoreTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractSessionBytesStoreTest.java
@@ -179,10 +179,19 @@ public abstract class AbstractSessionBytesStoreTest {
 
     abstract StoreType storeType();
 
+    /** Overridden by subclasses to exercise the transactional (staged-write) 
code path. */
+    boolean transactional() {
+        return false;
+    }
+
     @BeforeEach
     public void setUp() {
         sessionStore = buildSessionStore(RETENTION_PERIOD, Serdes.String(), 
Serdes.Long());
         recordCollector = new MockRecordCollector();
+        final Properties streamsConfig = StreamsTestUtils.getStreamsConfig();
+        if (transactional()) {
+            streamsConfig.put(StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG, 
true);
+        }
         context = new InternalMockProcessorContext<>(
             TestUtils.tempDirectory(),
             Serdes.String(),
@@ -191,7 +200,8 @@ public abstract class AbstractSessionBytesStoreTest {
             new ThreadCache(
                 new LogContext("testCache"),
                 0,
-                new MockStreamsMetrics(new Metrics())));
+                new MockStreamsMetrics(new Metrics())),
+            new StreamsConfig(streamsConfig));
         context.setTime(1L);
 
         sessionStore.init(context, sessionStore);
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractWindowBytesStoreTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractWindowBytesStoreTest.java
index c5e9c5b59fd..edcb73bad02 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractWindowBytesStoreTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractWindowBytesStoreTest.java
@@ -99,12 +99,21 @@ public abstract class AbstractWindowBytesStoreTest {
                                                        final boolean 
retainDuplicates,
                                                        final Serde<K> keySerde,
                                                        final Serde<V> 
valueSerde);
+    /** Overridden by subclasses to exercise the transactional (staged-write) 
code path. */
+    boolean transactional() {
+        return false;
+    }
+
     @BeforeEach
     protected void setup() {
-        
+
         windowStore = buildWindowStore(RETENTION_PERIOD, WINDOW_SIZE, false, 
Serdes.Integer(), Serdes.String());
 
         recordCollector = new MockRecordCollector();
+        final Properties streamsConfig = StreamsTestUtils.getStreamsConfig();
+        if (transactional()) {
+            streamsConfig.put(StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG, 
true);
+        }
         context = new InternalMockProcessorContext<>(
             baseDir,
             Serdes.String(),
@@ -113,7 +122,8 @@ public abstract class AbstractWindowBytesStoreTest {
             new ThreadCache(
                 new LogContext("testCache"),
                 0,
-                new MockStreamsMetrics(new Metrics())));
+                new MockStreamsMetrics(new Metrics())),
+            new StreamsConfig(streamsConfig));
         context.setTime(1L);
 
         windowStore.init(context, windowStore);
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryTransactionalSessionStoreTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryTransactionalSessionStoreTest.java
new file mode 100644
index 00000000000..260b6e08d2e
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryTransactionalSessionStoreTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.kafka.streams.state.internals;
+
+import org.apache.kafka.streams.kstream.Windowed;
+import org.apache.kafka.streams.kstream.internals.SessionWindow;
+import org.apache.kafka.streams.state.KeyValueIterator;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Set;
+
+import static org.apache.kafka.test.StreamsTestUtils.valuesToSet;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Runs the full {@link AbstractSessionBytesStoreTest} suite against the 
in-memory session store with
+ * transactional state stores enabled, so the staged-write/merge path is held 
to the same behavioural
+ * contract as the non-transactional store. Adds transactional-specific cases 
that the base suite (which
+ * only ever reads staged, un-committed writes) does not cover.
+ */
+public class InMemoryTransactionalSessionStoreTest extends 
AbstractSessionBytesStoreTest {
+
+    @Override
+    StoreType storeType() {
+        return StoreType.InMemoryStore;
+    }
+
+    @Override
+    boolean transactional() {
+        return true;
+    }
+
+    @Test
+    public void shouldReadAcrossCommitBoundaries() {
+        final Windowed<String> a0 = new Windowed<>("a", new SessionWindow(0, 
0));
+        final Windowed<String> a1 = new Windowed<>("a", new SessionWindow(10, 
20));
+
+        sessionStore.put(a0, 1L);                         // staged
+        sessionStore.commit(Collections.emptyMap());      // -> committed
+        sessionStore.put(a1, 2L);                         // staged on top of 
committed
+
+        // committed (a0) and staged (a1) are both visible before commit
+        try (final KeyValueIterator<Windowed<String>, Long> it = 
sessionStore.fetch("a")) {
+            assertEquals(Set.of(1L, 2L), valuesToSet(it));
+        }
+        sessionStore.commit(Collections.emptyMap());      // a1 -> committed; 
still visible
+        try (final KeyValueIterator<Windowed<String>, Long> it = 
sessionStore.fetch("a")) {
+            assertEquals(Set.of(1L, 2L), valuesToSet(it));
+        }
+
+        // a staged tombstone hides the committed session, and stays hidden 
after commit
+        sessionStore.remove(a0);
+        try (final KeyValueIterator<Windowed<String>, Long> it = 
sessionStore.fetch("a")) {
+            assertEquals(Set.of(2L), valuesToSet(it));
+        }
+        sessionStore.commit(Collections.emptyMap());
+        try (final KeyValueIterator<Windowed<String>, Long> it = 
sessionStore.fetch("a")) {
+            assertEquals(Set.of(2L), valuesToSet(it));
+        }
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryTransactionalWindowStoreTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryTransactionalWindowStoreTest.java
new file mode 100644
index 00000000000..5660dbebae0
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryTransactionalWindowStoreTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.kafka.streams.state.internals;
+
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.kstream.Windowed;
+import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.streams.state.Stores;
+import org.apache.kafka.streams.state.WindowStore;
+import org.apache.kafka.streams.state.WindowStoreIterator;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static java.time.Duration.ofMillis;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * Runs the full {@link AbstractWindowBytesStoreTest} suite against the 
in-memory window store with
+ * transactional state stores enabled, so the staged-write/merge path is held 
to the same behavioural
+ * contract as the non-transactional store.
+ */
+public class InMemoryTransactionalWindowStoreTest extends 
AbstractWindowBytesStoreTest {
+
+    private static final String STORE_NAME = 
"InMemoryTransactionalWindowStore";
+
+    @Override
+    <K, V> WindowStore<K, V> buildWindowStore(final long retentionPeriod,
+                                              final long windowSize,
+                                              final boolean retainDuplicates,
+                                              final Serde<K> keySerde,
+                                              final Serde<V> valueSerde) {
+        return Stores.windowStoreBuilder(
+            Stores.inMemoryWindowStore(
+                STORE_NAME,
+                ofMillis(retentionPeriod),
+                ofMillis(windowSize),
+                retainDuplicates),
+            keySerde,
+            valueSerde)
+            .build();
+    }
+
+    @Override
+    boolean transactional() {
+        return true;
+    }
+
+    /**
+     * Transactional reads are snapshot-isolated: an open iterator reflects 
the staged state as of when
+     * it was opened and does not observe records staged afterwards (the 
non-transactional store iterates
+     * the live map and does observe them). It must still not throw. This 
overrides the base test's
+     * live-iteration expectation accordingly.
+     */
+    @Test
+    @Override
+    public void shouldNotThrowConcurrentModificationException() {
+        long currentTime = 0;
+        windowStore.put(1, "one", currentTime);
+
+        currentTime += WINDOW_SIZE * 10;
+        windowStore.put(1, "two", currentTime);
+
+        try (final KeyValueIterator<Windowed<Integer>, String> iterator = 
windowStore.all()) {
+            currentTime += WINDOW_SIZE * 10;
+            windowStore.put(1, "three", currentTime);
+
+            currentTime += WINDOW_SIZE * 10;
+            windowStore.put(2, "four", currentTime);
+
+            assertEquals(windowedPair(1, "one", 0), iterator.next());
+            assertEquals(windowedPair(1, "two", WINDOW_SIZE * 10), 
iterator.next());
+            assertFalse(iterator.hasNext());
+        }
+    }
+
+    @Test
+    public void shouldReadAcrossCommitBoundaries() {
+        windowStore.put(1, "one", 0);                          // staged
+        windowStore.commit(Collections.emptyMap());            // -> committed
+        windowStore.put(1, "two", WINDOW_SIZE * 10);           // staged on 
top of committed
+
+        // committed (one) and staged (two) are both visible before commit, 
and after
+        try (final WindowStoreIterator<String> it = windowStore.fetch(1, 0, 
WINDOW_SIZE * 10)) {
+            assertEquals(new KeyValue<>(0L, "one"), it.next());
+            assertEquals(new KeyValue<>(WINDOW_SIZE * 10, "two"), it.next());
+            assertFalse(it.hasNext());
+        }
+        windowStore.commit(Collections.emptyMap());
+        try (final WindowStoreIterator<String> it = windowStore.fetch(1, 0, 
WINDOW_SIZE * 10)) {
+            assertEquals(new KeyValue<>(0L, "one"), it.next());
+            assertEquals(new KeyValue<>(WINDOW_SIZE * 10, "two"), it.next());
+            assertFalse(it.hasNext());
+        }
+
+        // a staged tombstone hides the committed record, and stays hidden 
after commit
+        windowStore.put(1, null, 0);
+        try (final WindowStoreIterator<String> it = windowStore.fetch(1, 0, 
WINDOW_SIZE * 10)) {
+            assertEquals(new KeyValue<>(WINDOW_SIZE * 10, "two"), it.next());
+            assertFalse(it.hasNext());
+        }
+        windowStore.commit(Collections.emptyMap());
+        try (final WindowStoreIterator<String> it = windowStore.fetch(1, 0, 
WINDOW_SIZE * 10)) {
+            assertEquals(new KeyValue<>(WINDOW_SIZE * 10, "two"), it.next());
+            assertFalse(it.hasNext());
+        }
+    }
+}

Reply via email to