-
Notifications
You must be signed in to change notification settings - Fork 14.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
KAFKA-10847: Fix spurious results on left/outer stream-stream joins #10462
Changes from all commits
525eafc
7b07bdb
810a136
c38d16a
ff46139
95a3da3
b248882
35cb615
c706ff6
5d8bbe5
165a309
f10ff2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,7 +17,9 @@ | |
|
||
package org.apache.kafka.streams.kstream.internals; | ||
|
||
import org.apache.kafka.clients.consumer.ConsumerRecord; | ||
import org.apache.kafka.common.serialization.Serde; | ||
import org.apache.kafka.common.utils.Time; | ||
import org.apache.kafka.streams.errors.StreamsException; | ||
import org.apache.kafka.streams.kstream.JoinWindows; | ||
import org.apache.kafka.streams.kstream.KStream; | ||
|
@@ -27,23 +29,45 @@ | |
import org.apache.kafka.streams.kstream.internals.graph.ProcessorGraphNode; | ||
import org.apache.kafka.streams.kstream.internals.graph.ProcessorParameters; | ||
import org.apache.kafka.streams.kstream.internals.graph.StreamStreamJoinNode; | ||
import org.apache.kafka.streams.state.internals.KeyAndJoinSide; | ||
import org.apache.kafka.streams.state.StoreBuilder; | ||
import org.apache.kafka.streams.state.Stores; | ||
import org.apache.kafka.streams.state.internals.LeftOrRightValue; | ||
import org.apache.kafka.streams.state.WindowBytesStoreSupplier; | ||
import org.apache.kafka.streams.state.WindowStore; | ||
import org.apache.kafka.streams.state.internals.KeyAndJoinSideSerde; | ||
import org.apache.kafka.streams.state.internals.RocksDbWindowBytesStoreSupplier; | ||
import org.apache.kafka.streams.state.internals.TimeOrderedWindowStoreBuilder; | ||
import org.apache.kafka.streams.state.internals.LeftOrRightValueSerde; | ||
|
||
import java.time.Duration; | ||
import java.util.Arrays; | ||
import java.util.HashSet; | ||
import java.util.Map; | ||
import java.util.Objects; | ||
import java.util.Optional; | ||
import java.util.Set; | ||
|
||
import static org.apache.kafka.streams.internals.ApiUtils.prepareMillisCheckFailMsgPrefix; | ||
import static org.apache.kafka.streams.internals.ApiUtils.validateMillisecondDuration; | ||
|
||
class KStreamImplJoin { | ||
|
||
private final InternalStreamsBuilder builder; | ||
private final boolean leftOuter; | ||
private final boolean rightOuter; | ||
|
||
static class MaxObservedStreamTime { | ||
private long maxObservedStreamTime = ConsumerRecord.NO_TIMESTAMP; | ||
|
||
public void advance(final long streamTime) { | ||
maxObservedStreamTime = Math.max(streamTime, maxObservedStreamTime); | ||
} | ||
|
||
public long get() { | ||
return maxObservedStreamTime; | ||
} | ||
} | ||
|
||
KStreamImplJoin(final InternalStreamsBuilder builder, | ||
final boolean leftOuter, | ||
|
@@ -118,20 +142,47 @@ public <K1, R, V1, V2> KStream<K1, R> join(final KStream<K1, V1> lhs, | |
final ProcessorGraphNode<K1, V2> otherWindowedStreamsNode = new ProcessorGraphNode<>(otherWindowStreamProcessorName, otherWindowStreamProcessorParams); | ||
builder.addGraphNode(otherGraphNode, otherWindowedStreamsNode); | ||
|
||
Optional<StoreBuilder<WindowStore<KeyAndJoinSide<K1>, LeftOrRightValue<V1, V2>>>> outerJoinWindowStore = Optional.empty(); | ||
if (leftOuter) { | ||
final String outerJoinSuffix = rightOuter ? "-outer-shared-join" : "-left-shared-join"; | ||
|
||
// Get the suffix index of the joinThisGeneratedName to build the outer join store name. | ||
final String outerJoinStoreGeneratedName = KStreamImpl.OUTERSHARED_NAME | ||
+ joinThisGeneratedName.substring( | ||
rightOuter | ||
? KStreamImpl.OUTERTHIS_NAME.length() | ||
: KStreamImpl.JOINTHIS_NAME.length()); | ||
|
||
final String outerJoinStoreName = userProvidedBaseStoreName == null ? outerJoinStoreGeneratedName : userProvidedBaseStoreName + outerJoinSuffix; | ||
|
||
outerJoinWindowStore = Optional.of(sharedOuterJoinWindowStoreBuilder(outerJoinStoreName, windows, streamJoinedInternal)); | ||
} | ||
|
||
// Time shared between joins to keep track of the maximum stream time | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this is only accessed single-thread, using an atomic long feels a bit overkill. We could probably maintain the "long maxObservedStreamTime" in this class, and pass in a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One disadvantage compared to using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's extremely subtle, but we cannot use For example: if we have a record cache upstream of this join, it will delay the propogation of records (and their accompanying timestamps) by time amount There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mjsax is correct that there is a bug that the processor-local stream time gets reset on rebalance/restart. It would be good to fix it, but with the current architecture, the only correct solution is to persist the processor-local stream time. Another approach we've discussed is to remove the time-delay effect of the record cache. |
||
final MaxObservedStreamTime maxObservedStreamTime = new MaxObservedStreamTime(); | ||
|
||
final KStreamKStreamJoin<K1, R, V1, V2> joinThis = new KStreamKStreamJoin<>( | ||
true, | ||
otherWindowStore.name(), | ||
windows.beforeMs, | ||
windows.afterMs, | ||
windows.gracePeriodMs(), | ||
joiner, | ||
leftOuter | ||
leftOuter, | ||
outerJoinWindowStore.map(StoreBuilder::name), | ||
maxObservedStreamTime | ||
); | ||
|
||
final KStreamKStreamJoin<K1, R, V2, V1> joinOther = new KStreamKStreamJoin<>( | ||
false, | ||
thisWindowStore.name(), | ||
windows.afterMs, | ||
windows.beforeMs, | ||
windows.gracePeriodMs(), | ||
AbstractStream.reverseJoinerWithKey(joiner), | ||
rightOuter | ||
rightOuter, | ||
outerJoinWindowStore.map(StoreBuilder::name), | ||
maxObservedStreamTime | ||
); | ||
|
||
final PassThrough<K1, R> joinMerge = new PassThrough<>(); | ||
|
@@ -149,6 +200,7 @@ public <K1, R, V1, V2> KStream<K1, R> join(final KStream<K1, V1> lhs, | |
.withOtherWindowStoreBuilder(otherWindowStore) | ||
.withThisWindowedStreamProcessorParameters(thisWindowStreamProcessorParams) | ||
.withOtherWindowedStreamProcessorParameters(otherWindowStreamProcessorParams) | ||
.withOuterJoinWindowStoreBuilder(outerJoinWindowStore) | ||
.withValueJoiner(joiner) | ||
.withNodeName(joinMergeName); | ||
|
||
|
@@ -211,6 +263,66 @@ private static <K, V> StoreBuilder<WindowStore<K, V>> joinWindowStoreBuilder(fin | |
return builder; | ||
} | ||
|
||
@SuppressWarnings("unchecked") | ||
private static <K, V1, V2> StoreBuilder<WindowStore<KeyAndJoinSide<K>, LeftOrRightValue<V1, V2>>> sharedOuterJoinWindowStoreBuilder(final String storeName, | ||
final JoinWindows windows, | ||
final StreamJoinedInternal<K, V1, V2> streamJoinedInternal) { | ||
final StoreBuilder<WindowStore<KeyAndJoinSide<K>, LeftOrRightValue<V1, V2>>> builder = new TimeOrderedWindowStoreBuilder<KeyAndJoinSide<K>, LeftOrRightValue<V1, V2>>( | ||
persistentTimeOrderedWindowStore( | ||
storeName + "-store", | ||
Duration.ofMillis(windows.size() + windows.gracePeriodMs()), | ||
Duration.ofMillis(windows.size()) | ||
), | ||
new KeyAndJoinSideSerde<>(streamJoinedInternal.keySerde()), | ||
new LeftOrRightValueSerde(streamJoinedInternal.valueSerde(), streamJoinedInternal.otherValueSerde()), | ||
Time.SYSTEM | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we pass a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It will require more changes just to allow that. The Also, the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok. Maybe good enough as-is. (Fair point that we don't mock it in other stores either -- maybe there was never any demand to be able to mock it. As you said, we could change it as follow up if needed.) |
||
); | ||
if (streamJoinedInternal.loggingEnabled()) { | ||
builder.withLoggingEnabled(streamJoinedInternal.logConfig()); | ||
} else { | ||
builder.withLoggingDisabled(); | ||
} | ||
|
||
return builder; | ||
} | ||
|
||
// This method has same code as Store.persistentWindowStore(). But TimeOrderedWindowStore is | ||
// a non-public API, so we need to keep duplicate code until it becomes public. | ||
private static WindowBytesStoreSupplier persistentTimeOrderedWindowStore(final String storeName, | ||
final Duration retentionPeriod, | ||
final Duration windowSize) { | ||
Objects.requireNonNull(storeName, "name cannot be null"); | ||
final String rpMsgPrefix = prepareMillisCheckFailMsgPrefix(retentionPeriod, "retentionPeriod"); | ||
final long retentionMs = validateMillisecondDuration(retentionPeriod, rpMsgPrefix); | ||
final String wsMsgPrefix = prepareMillisCheckFailMsgPrefix(windowSize, "windowSize"); | ||
final long windowSizeMs = validateMillisecondDuration(windowSize, wsMsgPrefix); | ||
|
||
final long segmentInterval = Math.max(retentionMs / 2, 60_000L); | ||
|
||
if (retentionMs < 0L) { | ||
throw new IllegalArgumentException("retentionPeriod cannot be negative"); | ||
} | ||
if (windowSizeMs < 0L) { | ||
throw new IllegalArgumentException("windowSize cannot be negative"); | ||
} | ||
if (segmentInterval < 1L) { | ||
throw new IllegalArgumentException("segmentInterval cannot be zero or negative"); | ||
} | ||
if (windowSizeMs > retentionMs) { | ||
throw new IllegalArgumentException("The retention period of the window store " | ||
+ storeName + " must be no smaller than its window size. Got size=[" | ||
+ windowSizeMs + "], retention=[" + retentionMs + "]"); | ||
} | ||
|
||
return new RocksDbWindowBytesStoreSupplier( | ||
storeName, | ||
retentionMs, | ||
segmentInterval, | ||
windowSizeMs, | ||
true, | ||
RocksDbWindowBytesStoreSupplier.WindowStoreTypes.TIME_ORDERED_WINDOW_STORE); | ||
} | ||
|
||
private static <K, V> StoreBuilder<WindowStore<K, V>> joinWindowStoreBuilderFromSupplier(final WindowBytesStoreSupplier storeSupplier, | ||
final Serde<K> keySerde, | ||
final Serde<V> valueSerde) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,17 +19,24 @@ | |
import org.apache.kafka.common.metrics.Sensor; | ||
import org.apache.kafka.streams.KeyValue; | ||
import org.apache.kafka.streams.kstream.ValueJoinerWithKey; | ||
import org.apache.kafka.streams.kstream.Windowed; | ||
import org.apache.kafka.streams.processor.AbstractProcessor; | ||
import org.apache.kafka.streams.processor.Processor; | ||
import org.apache.kafka.streams.processor.ProcessorContext; | ||
import org.apache.kafka.streams.processor.ProcessorSupplier; | ||
import org.apache.kafka.streams.processor.To; | ||
import org.apache.kafka.streams.processor.internals.metrics.StreamsMetricsImpl; | ||
import org.apache.kafka.streams.state.KeyValueIterator; | ||
import org.apache.kafka.streams.state.WindowStore; | ||
import org.apache.kafka.streams.state.WindowStoreIterator; | ||
import org.apache.kafka.streams.state.internals.KeyAndJoinSide; | ||
import org.apache.kafka.streams.state.internals.LeftOrRightValue; | ||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
|
||
import java.util.Optional; | ||
import java.util.function.Predicate; | ||
|
||
import static org.apache.kafka.streams.processor.internals.metrics.TaskMetrics.droppedRecordsSensorOrSkippedRecordsSensor; | ||
|
||
class KStreamKStreamJoin<K, R, V1, V2> implements ProcessorSupplier<K, V1> { | ||
|
@@ -38,20 +45,33 @@ class KStreamKStreamJoin<K, R, V1, V2> implements ProcessorSupplier<K, V1> { | |
private final String otherWindowName; | ||
private final long joinBeforeMs; | ||
private final long joinAfterMs; | ||
private final long joinGraceMs; | ||
|
||
private final ValueJoinerWithKey<? super K, ? super V1, ? super V2, ? extends R> joiner; | ||
private final boolean outer; | ||
private final Optional<String> outerJoinWindowName; | ||
private final boolean isLeftSide; | ||
|
||
private final KStreamImplJoin.MaxObservedStreamTime maxObservedStreamTime; | ||
|
||
KStreamKStreamJoin(final String otherWindowName, | ||
KStreamKStreamJoin(final boolean isLeftSide, | ||
final String otherWindowName, | ||
final long joinBeforeMs, | ||
final long joinAfterMs, | ||
final long joinGraceMs, | ||
final ValueJoinerWithKey<? super K, ? super V1, ? super V2, ? extends R> joiner, | ||
final boolean outer) { | ||
final boolean outer, | ||
final Optional<String> outerJoinWindowName, | ||
final KStreamImplJoin.MaxObservedStreamTime maxObservedStreamTime) { | ||
this.isLeftSide = isLeftSide; | ||
this.otherWindowName = otherWindowName; | ||
this.joinBeforeMs = joinBeforeMs; | ||
this.joinAfterMs = joinAfterMs; | ||
this.joinGraceMs = joinGraceMs; | ||
this.joiner = joiner; | ||
this.outer = outer; | ||
this.outerJoinWindowName = outerJoinWindowName; | ||
this.maxObservedStreamTime = maxObservedStreamTime; | ||
} | ||
|
||
@Override | ||
|
@@ -60,21 +80,24 @@ public Processor<K, V1> get() { | |
} | ||
|
||
private class KStreamKStreamJoinProcessor extends AbstractProcessor<K, V1> { | ||
private final Predicate<Windowed<KeyAndJoinSide<K>>> recordWindowHasClosed = | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit : I think this function can just be inlined now? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
windowedKey -> windowedKey.window().start() + joinAfterMs + joinGraceMs < maxObservedStreamTime.get(); | ||
|
||
private WindowStore<K, V2> otherWindow; | ||
private WindowStore<K, V2> otherWindowStore; | ||
private StreamsMetricsImpl metrics; | ||
private Sensor droppedRecordsSensor; | ||
private Optional<WindowStore<KeyAndJoinSide<K>, LeftOrRightValue>> outerJoinWindowStore = Optional.empty(); | ||
|
||
@SuppressWarnings("unchecked") | ||
@Override | ||
public void init(final ProcessorContext context) { | ||
super.init(context); | ||
metrics = (StreamsMetricsImpl) context.metrics(); | ||
droppedRecordsSensor = droppedRecordsSensorOrSkippedRecordsSensor(Thread.currentThread().getName(), context.taskId().toString(), metrics); | ||
otherWindow = (WindowStore<K, V2>) context.getStateStore(otherWindowName); | ||
otherWindowStore = context.getStateStore(otherWindowName); | ||
outerJoinWindowStore = outerJoinWindowName.map(name -> context.getStateStore(name)); | ||
} | ||
|
||
|
||
@Override | ||
public void process(final K key, final V1 value) { | ||
// we do join iff keys are equal, thus, if key is null we cannot join and just ignore the record | ||
|
@@ -98,18 +121,92 @@ key, value, context().topic(), context().partition(), context().offset() | |
final long timeFrom = Math.max(0L, inputRecordTimestamp - joinBeforeMs); | ||
final long timeTo = Math.max(0L, inputRecordTimestamp + joinAfterMs); | ||
|
||
try (final WindowStoreIterator<V2> iter = otherWindow.fetch(key, timeFrom, timeTo)) { | ||
maxObservedStreamTime.advance(inputRecordTimestamp); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Side improvement: I think we should skip late record directly and also record it in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
// Emit all non-joined records which window has closed | ||
if (inputRecordTimestamp == maxObservedStreamTime.get()) { | ||
outerJoinWindowStore.ifPresent(store -> emitNonJoinedOuterRecords(store)); | ||
} | ||
|
||
try (final WindowStoreIterator<V2> iter = otherWindowStore.fetch(key, timeFrom, timeTo)) { | ||
while (iter.hasNext()) { | ||
needOuterJoin = false; | ||
final KeyValue<Long, V2> otherRecord = iter.next(); | ||
final long otherRecordTimestamp = otherRecord.key; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Side improvement: atm We should extend our tests accordingly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting. Is this a current bug with the old join semantics? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it a bug in the current implementation... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems this comment was not address yet. Or do you not want to add this additional fix into this PR? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @spena just ping to make sure you get this on the follow-up PR. |
||
|
||
outerJoinWindowStore.ifPresent(store -> { | ||
// Delete the joined record from the non-joined outer window store | ||
store.put(KeyAndJoinSide.make(!isLeftSide, key), null, otherRecordTimestamp); | ||
}); | ||
|
||
context().forward( | ||
key, | ||
joiner.apply(key, value, otherRecord.value), | ||
To.all().withTimestamp(Math.max(inputRecordTimestamp, otherRecord.key))); | ||
To.all().withTimestamp(Math.max(inputRecordTimestamp, otherRecordTimestamp))); | ||
} | ||
|
||
if (needOuterJoin) { | ||
context().forward(key, joiner.apply(key, value, null)); | ||
// The maxStreamTime contains the max time observed in both sides of the join. | ||
// Having access to the time observed in the other join side fixes the following | ||
// problem: | ||
// | ||
// Say we have a window size of 5 seconds | ||
// 1. A non-joined record wth time T10 is seen in the left-topic (maxLeftStreamTime: 10) | ||
// The record is not processed yet, and is added to the outer-join store | ||
// 2. A non-joined record with time T2 is seen in the right-topic (maxRightStreamTime: 2) | ||
// The record is not processed yet, and is added to the outer-join store | ||
// 3. A joined record with time T11 is seen in the left-topic (maxLeftStreamTime: 11) | ||
// It is time to look at the expired records. T10 and T2 should be emitted, but | ||
// because T2 was late, then it is not fetched by the window store, so it is not processed | ||
// | ||
// See KStreamKStreamLeftJoinTest.testLowerWindowBound() tests | ||
// | ||
// This condition below allows us to process the out-of-order records without the need | ||
// to hold it in the temporary outer store | ||
if (!outerJoinWindowStore.isPresent() || timeTo < maxObservedStreamTime.get()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if I understand the second condition? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the motivation is that if the current record's timestamp is too small (i.e. it is too late), then it should not be added into the book-keeping store but can be "expired" immediately. But I also feel the condition seems a bit off here: for the record to be "too late", its timestamp just need to be smaller than the expiration boundary, which is observed-stream-time - join-after - grace-period, right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, while we should have a check like this, it seems it should go to the top of this method, next to the key/value We can also There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @spena seems there are a few different conditions we can consider here:
|
||
context().forward(key, joiner.apply(key, value, null)); | ||
} else { | ||
outerJoinWindowStore.ifPresent(store -> store.put( | ||
KeyAndJoinSide.make(isLeftSide, key), | ||
LeftOrRightValue.make(isLeftSide, value), | ||
inputRecordTimestamp)); | ||
} | ||
} | ||
} | ||
} | ||
|
||
@SuppressWarnings("unchecked") | ||
private void emitNonJoinedOuterRecords(final WindowStore<KeyAndJoinSide<K>, LeftOrRightValue> store) { | ||
try (final KeyValueIterator<Windowed<KeyAndJoinSide<K>>, LeftOrRightValue> it = store.all()) { | ||
while (it.hasNext()) { | ||
final KeyValue<Windowed<KeyAndJoinSide<K>>, LeftOrRightValue> record = it.next(); | ||
|
||
final Windowed<KeyAndJoinSide<K>> windowedKey = record.key; | ||
final LeftOrRightValue value = record.value; | ||
|
||
// Skip next records if window has not closed | ||
if (windowedKey.window().start() + joinAfterMs + joinGraceMs >= maxObservedStreamTime.get()) { | ||
break; | ||
} | ||
|
||
final K key = windowedKey.key().getKey(); | ||
final long time = windowedKey.window().start(); | ||
|
||
final R nullJoinedValue; | ||
if (isLeftSide) { | ||
nullJoinedValue = joiner.apply(key, | ||
(V1) value.getLeftValue(), | ||
(V2) value.getRightValue()); | ||
} else { | ||
nullJoinedValue = joiner.apply(key, | ||
(V1) value.getRightValue(), | ||
(V2) value.getLeftValue()); | ||
} | ||
|
||
context().forward(key, nullJoinedValue, To.all().withTimestamp(time)); | ||
|
||
// Delete the key from the outer window store now it is emitted | ||
store.put(record.key.key(), null, record.key.window().start()); | ||
} | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if I understand? Why use "outer" or " this" here? If the store is shared, neither one seems to make sense? Overall naming of processor and stores is tricky.. Can we actually add a corresponding test that compares generated and expected
TopologyDescription
for this case?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I initially generated a name with a new index for the shared store. However, seems this was going to cause incompatibilities in the topology because the new indexed increasing. Instead, now I just get the index from one of the current join stores. Why doesn't make sense? Is there another way to get an index? Or, do I really need to append an index at the end of the shared store?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that we should not use one more index to avoid compatibility issues... Maybe the question is really (just for my better understanding), what would the name be, ie, could be give a concrete example (with and without
Named
parameter)? That is also why I asked for a test usingTopologyDescription
-- makes it easier to wrap my head around.