Skip to content

Commit 38b8e93

Browse files
fix(intervalCollection): Fix bugs seen from oracle (microsoft#25961)
This PR fixes two bugs which were seen in the oracle testing for interval collection. 1. Missing `deleteInterval` event when intervals slide off during rebasing When an interval slides off the string during rebasing the code was calling `removeExistingInterval` which removes the interval without emitting a `deleteInterval` event. The oracle still thinks the interval exists, causing validation failures. 2. Events emitted for deleted intervals When an interval has been deleted `latestInterval === undefined`, but remote ops are still being acknowledged, the code emits events with undefined as the interval, violating the API contract that says `interval` must never be undefined.
1 parent fa03d38 commit 38b8e93

File tree

3 files changed

+271
-5
lines changed

3 files changed

+271
-5
lines changed

packages/dds/sequence/src/intervalCollection.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,7 +1431,7 @@ export class IntervalCollection
14311431
}
14321432
}
14331433

1434-
if (deltaProps !== undefined && Object.keys(deltaProps).length > 0) {
1434+
if (isLatestInterval && deltaProps !== undefined && Object.keys(deltaProps).length > 0) {
14351435
this.emit("propertyChanged", latestInterval, deltaProps, local, op);
14361436
this.emit("changed", latestInterval, deltaProps, undefined, local, false);
14371437
}
@@ -1482,17 +1482,18 @@ export class IntervalCollection
14821482
const rebasedEndpoint = this.computeRebasedPositions(localOpMetadata, squash);
14831483
const localInterval = this.getIntervalById(id);
14841484

1485-
// if the interval slid off the string, rebase the op to be a noop and delete the interval.
1485+
// if the interval slides off the string, rebase the op to be a noop and delete the interval.
14861486
if (rebasedEndpoint === "detached") {
14871487
if (
14881488
localInterval !== undefined &&
14891489
(localInterval === interval || localOpMetadata.type === "add")
14901490
) {
1491-
this.localCollection?.removeExistingInterval(localInterval);
1491+
// Use deleteExistingInterval (not removeExistingInterval) to ensure the deleteInterval
1492+
// event is emitted when intervals slide off during rebasing.
1493+
this.deleteExistingInterval({ interval: localInterval, local: true });
14921494
}
14931495
return undefined;
14941496
}
1495-
14961497
const { start, end } = rebasedEndpoint;
14971498
if (
14981499
interval.start.getSegment() !== start.segment ||

packages/dds/sequence/src/test/intervalCollection.rollback.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,4 +411,49 @@ describe("SharedString IntervalCollection rollback", () => {
411411
"Interval should not be restored after remote delete and rollback",
412412
);
413413
});
414+
415+
it("emits deleteInterval event during rollback of add operation", () => {
416+
const { sharedString, containerRuntimeFactory, containerRuntime, collection } =
417+
setupRollbackTest();
418+
sharedString.insertText(0, "ABCD");
419+
containerRuntimeFactory.processAllMessages();
420+
421+
// Track deleteInterval events
422+
let deleteEventFired = false;
423+
let deletedIntervalId: string | undefined;
424+
collection.on("deleteInterval", (deletedInterval, local, op) => {
425+
if (deletedInterval !== undefined) {
426+
deleteEventFired = true;
427+
deletedIntervalId = deletedInterval.getIntervalId();
428+
}
429+
});
430+
431+
// Add interval locally
432+
const interval = collection.add({ start: 1, end: 3 });
433+
const intervalId = interval.getIntervalId();
434+
435+
assert.notEqual(
436+
collection.getIntervalById(intervalId),
437+
undefined,
438+
"interval should exist",
439+
);
440+
441+
// Rollback the add operation
442+
containerRuntime.rollback?.();
443+
444+
// Verify deleteInterval event was emitted during rollback
445+
assert.equal(
446+
deleteEventFired,
447+
true,
448+
"deleteInterval event should be emitted during rollback",
449+
);
450+
assert.equal(deletedIntervalId, intervalId, "deleted interval ID should match");
451+
452+
// Verify interval was removed
453+
assert.equal(
454+
collection.getIntervalById(intervalId),
455+
undefined,
456+
"interval should be removed after rollback",
457+
);
458+
});
414459
});

packages/dds/sequence/src/test/intervalRebasing.spec.ts

Lines changed: 221 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { strict as assert } from "assert";
88
import { Side } from "@fluidframework/merge-tree/internal";
99
import { useStrictPartialLengthChecks } from "@fluidframework/merge-tree/internal/test";
1010
import { MockContainerRuntimeFactoryForReconnection } from "@fluidframework/test-runtime-utils/internal";
11-
1211
import { IntervalStickiness } from "../intervals/index.js";
1312

1413
import { Client, assertConsistent, assertSequenceIntervals } from "./intervalTestUtils.js";
@@ -592,4 +591,225 @@ describe("interval rebasing", () => {
592591

593592
assertSequenceIntervals(clients[0].sharedString, intervals, [{ start: 0, end: 2 }]);
594593
});
594+
595+
it("emits deleteInterval event when interval slides off during rebasing", async () => {
596+
const { containerRuntimeFactory, clients } = setup3Clients();
597+
598+
// Set up initial content - use a scenario that causes interval to slide off
599+
clients[0].sharedString.insertText(0, "012Z45");
600+
clients[2].sharedString.insertText(0, "X");
601+
containerRuntimeFactory.processAllMessages();
602+
await assertConsistent(clients);
603+
604+
clients[1].sharedString.insertText(0, "01234567");
605+
clients[0].containerRuntime.connected = false;
606+
containerRuntimeFactory.processAllMessages();
607+
await assertConsistent(clients);
608+
609+
clients[0].sharedString.insertText(0, "ABCDEFGHIJKLMN");
610+
const collection_0 = clients[0].sharedString.getIntervalCollection("test");
611+
const interval = collection_0.add({ start: 20, end: 20 });
612+
const intervalId = interval.getIntervalId();
613+
614+
// Track deleteInterval events
615+
let deleteEventFired = false;
616+
let deletedIntervalId: string | undefined;
617+
// eslint-disable-next-line @typescript-eslint/no-shadow
618+
collection_0.on("deleteInterval", (interval, local, op) => {
619+
deleteEventFired = true;
620+
deletedIntervalId = interval?.getIntervalId();
621+
});
622+
623+
// Cause the interval to slide off
624+
clients[2].sharedString.removeRange(13, 15);
625+
containerRuntimeFactory.processAllMessages();
626+
await assertConsistent(clients);
627+
628+
// Reconnect - this triggers rebasing where the interval will slide off
629+
clients[0].containerRuntime.connected = true;
630+
containerRuntimeFactory.processAllMessages();
631+
await assertConsistent(clients);
632+
633+
// Verify the deleteInterval event was emitted
634+
// If the interval slides off, it should have been deleted and event emitted
635+
const finalInterval = collection_0.getIntervalById(intervalId);
636+
if (!finalInterval) {
637+
// Interval slide off - verify event was emitted
638+
assert.equal(
639+
deleteEventFired,
640+
true,
641+
"deleteInterval event should be emitted when interval slides off during rebasing",
642+
);
643+
assert.equal(deletedIntervalId, intervalId, "deleted interval ID should match");
644+
}
645+
});
646+
647+
it("does not emit events with undefined interval for deleted intervals", async () => {
648+
const { containerRuntimeFactory, clients } = setup3Clients();
649+
650+
// Set up initial content
651+
clients[0].sharedString.insertText(0, "ABCD");
652+
containerRuntimeFactory.processAllMessages();
653+
await assertConsistent(clients);
654+
655+
// Add interval on client 0
656+
const collection_0 = clients[0].sharedString.getIntervalCollection("test");
657+
const collection_1 = clients[1].sharedString.getIntervalCollection("test");
658+
const interval_0 = collection_0.add({ start: 1, end: 3 }); // BC
659+
const intervalId = interval_0.getIntervalId();
660+
661+
containerRuntimeFactory.processAllMessages();
662+
await assertConsistent(clients);
663+
664+
// Track all events to ensure interval is never undefined
665+
let eventWithUndefinedInterval = false;
666+
const checkInterval = (interval) => {
667+
if (interval === undefined) {
668+
eventWithUndefinedInterval = true;
669+
}
670+
};
671+
672+
collection_1.on("changeInterval", (interval) => checkInterval(interval));
673+
collection_1.on("propertyChanged", (interval) => checkInterval(interval));
674+
collection_1.on("changed", (interval) => checkInterval(interval));
675+
676+
// Client 0: Delete the interval locally
677+
collection_0.removeIntervalById(intervalId);
678+
679+
// Client 1: Change properties of the same interval before seeing the delete
680+
clients[1].containerRuntime.connected = false;
681+
collection_1.change(intervalId, { props: { foo: "bar" } });
682+
683+
// Process delete op first
684+
containerRuntimeFactory.processOneMessage();
685+
686+
// Reconnect client 1 and process the property change
687+
clients[1].containerRuntime.connected = true;
688+
containerRuntimeFactory.processAllMessages();
689+
await assertConsistent(clients);
690+
691+
// Verify no events were emitted with undefined interval
692+
assert.equal(
693+
eventWithUndefinedInterval,
694+
false,
695+
"events should never be emitted with undefined interval",
696+
);
697+
698+
// Verify interval is deleted on both clients
699+
assert.equal(
700+
collection_0.getIntervalById(intervalId),
701+
undefined,
702+
"interval should be deleted on client 0",
703+
);
704+
assert.equal(
705+
collection_1.getIntervalById(intervalId),
706+
undefined,
707+
"interval should be deleted on client 1",
708+
);
709+
});
710+
711+
it("does not emit changeInterval event with undefined for deleted interval with endpoint changes", async () => {
712+
const { containerRuntimeFactory, clients } = setup3Clients();
713+
714+
clients[0].sharedString.insertText(0, "ABCDEFGH");
715+
containerRuntimeFactory.processAllMessages();
716+
await assertConsistent(clients);
717+
718+
const collection_0 = clients[0].sharedString.getIntervalCollection("test");
719+
const collection_1 = clients[1].sharedString.getIntervalCollection("test");
720+
const interval_0 = collection_0.add({ start: 1, end: 3 });
721+
const intervalId = interval_0.getIntervalId();
722+
723+
containerRuntimeFactory.processAllMessages();
724+
await assertConsistent(clients);
725+
726+
// Track changeInterval events to ensure interval is never undefined
727+
let changeEventWithUndefinedInterval = false;
728+
collection_1.on("changeInterval", (interval, previousInterval, local, op) => {
729+
if (interval === undefined) {
730+
changeEventWithUndefinedInterval = true;
731+
}
732+
});
733+
734+
// Client 0: Delete the interval
735+
collection_0.removeIntervalById(intervalId);
736+
737+
// Client 1: Change endpoints of the same interval before seeing the delete
738+
clients[1].containerRuntime.connected = false;
739+
collection_1.change(intervalId, { start: 2, end: 4 });
740+
741+
// Process delete op first
742+
containerRuntimeFactory.processOneMessage();
743+
744+
// Reconnect client 1 and process the endpoint change
745+
clients[1].containerRuntime.connected = true;
746+
containerRuntimeFactory.processAllMessages();
747+
await assertConsistent(clients);
748+
749+
// Verify no changeInterval events were emitted with undefined interval
750+
assert.equal(
751+
changeEventWithUndefinedInterval,
752+
false,
753+
"changeInterval event should never be emitted with undefined interval",
754+
);
755+
756+
// Verify interval is deleted on both clients
757+
assert.equal(collection_0.getIntervalById(intervalId), undefined);
758+
assert.equal(collection_1.getIntervalById(intervalId), undefined);
759+
});
760+
761+
it("does not emit events with undefined when interval deleted during concurrent add", async () => {
762+
const { containerRuntimeFactory, clients } = setup3Clients();
763+
764+
clients[0].sharedString.insertText(0, "ABCD");
765+
containerRuntimeFactory.processAllMessages();
766+
await assertConsistent(clients);
767+
768+
const collection_0 = clients[0].sharedString.getIntervalCollection("test");
769+
const collection_1 = clients[1].sharedString.getIntervalCollection("test");
770+
771+
// Client 0 adds an interval
772+
const interval_0 = collection_0.add({ start: 1, end: 3 });
773+
const intervalId = interval_0.getIntervalId();
774+
775+
// Process client 0's add so both clients have the interval
776+
containerRuntimeFactory.processAllMessages();
777+
await assertConsistent(clients);
778+
779+
// Client 1 disconnects and tries to modify the interval
780+
clients[1].containerRuntime.connected = false;
781+
collection_1.change(intervalId, { props: { modified: true } });
782+
783+
// Meanwhile, Client 0 deletes the interval
784+
collection_0.removeIntervalById(intervalId);
785+
containerRuntimeFactory.processOneMessage();
786+
787+
// Track events on client 1 to ensure interval is never undefined
788+
let eventWithUndefinedInterval = false;
789+
const checkInterval = (interval) => {
790+
if (interval === undefined) {
791+
eventWithUndefinedInterval = true;
792+
}
793+
};
794+
795+
collection_1.on("deleteInterval", (interval) => checkInterval(interval));
796+
collection_1.on("propertyChanged", (interval) => checkInterval(interval));
797+
collection_1.on("changed", (interval) => checkInterval(interval));
798+
799+
// Reconnect client 1 and process its change (which arrives after delete)
800+
clients[1].containerRuntime.connected = true;
801+
containerRuntimeFactory.processAllMessages();
802+
await assertConsistent(clients);
803+
804+
// Verify no events were emitted with undefined interval
805+
assert.equal(
806+
eventWithUndefinedInterval,
807+
false,
808+
"no events should be emitted with undefined interval during concurrent modify/delete",
809+
);
810+
811+
// Verify final state - interval should be deleted on both
812+
assert.equal(collection_0.getIntervalById(intervalId), undefined);
813+
assert.equal(collection_1.getIntervalById(intervalId), undefined);
814+
});
595815
});

0 commit comments

Comments
 (0)