Skip to content

Commit d895f88

Browse files
feat(matrix): Add oracle for event testing (#25505)
This change introduces an oracle to track `conflicts` events in a SharedMatrix while accounting for changes, like row and column insertions or deletions. It tracks the latest conflicts in a shared matrix and adjusts them when rows or columns are inserted, removed, or shifted. Captures both the conflict winner (currentValue), conflicting value and the actual cell value (cellValue) to validate consistency. Key updates: 1. latestConflict Map: - Stores only the most recent conflict for each cell (row,col). - Simplifies validation logic and ensures that the oracle always checks the last winning value rather than needing to track an entire history of conflicts. 2. Event Listeners for insert/remove row/col Changes: - rowsChanged and colsChanged call updateLatestHistory to adjust conflict entries when rows or columns are inserted or deleted. - cellsChanged iterates over updated cells and captures the current cell value for existing conflicts, ensuring the oracle can verify the current state against the conflict’s expected winner. 3. updateLatestHistory Logic: - Iterates through the latestConflict map and determines which entries remain valid (keep = true) or should be removed if their row or column was deleted. - Shifts row or column indices when rows/columns are inserted beyond the affected range. - Uses a new map to avoid mutating latestConflict during iteration Questions: 1. Does updateLatestHistory correctly update conflicts for all row/column insertions and deletions? 2. Does the oracle’s structure and validation logic make sense, or is there any inaccuracy? 3. Are there additional edge cases that need to be handled? 4. Are the event listeners (rowsChanged, colsChanged, cellsChanged) sufficient to capture all relevant matrix updates? Is there anything more which can be captured here? 5. How can the matrix oracle track local pending changes for a cell? 6. Is there a bette way to validate the conflicting value and current value?
1 parent fe0dcfd commit d895f88

File tree

3 files changed

+251
-5
lines changed

3 files changed

+251
-5
lines changed

packages/dds/matrix/src/test/fuzz.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { isFluidHandle, toFluidHandleInternal } from "@fluidframework/runtime-ut
1919
import type { MatrixItem } from "../ops.js";
2020
import { SharedMatrix, type SharedMatrixFactory } from "../runtime.js";
2121

22+
import { hasSharedMatrixOracle } from "./matrixOracle.js";
23+
2224
/**
2325
* Supported cell values used within the fuzz model.
2426
*/
@@ -225,7 +227,17 @@ export const baseSharedMatrixModel: Omit<
225227
factory: SharedMatrix.getFactory(),
226228
generatorFactory: () => takeAsync(50, makeGenerator()),
227229
reducer: (state, operation) => reducer(state, operation),
228-
validateConsistency: async (a, b) => assertMatricesAreEquivalent(a.channel, b.channel),
230+
validateConsistency: async (a, b) => {
231+
if (hasSharedMatrixOracle(a.channel)) {
232+
a.channel.matrixOracle.validate();
233+
}
234+
235+
if (hasSharedMatrixOracle(b.channel)) {
236+
b.channel.matrixOracle.validate();
237+
}
238+
239+
return assertMatricesAreEquivalent(a.channel, b.channel);
240+
},
229241
minimizationTransforms: ["count", "start", "row", "col"].map((p) => (op) => {
230242
if (p in op && typeof op[p] === "number" && op[p] > 0) {
231243
op[p]--;

packages/dds/matrix/src/test/matrix.fuzz.spec.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,29 @@
33
* Licensed under the MIT License.
44
*/
55

6-
import * as path from "node:path";
7-
6+
import { TypedEventEmitter } from "@fluid-internal/client-utils";
87
import {
98
createDDSFuzzSuite,
9+
registerOracle,
10+
type DDSFuzzHarnessEvents,
1011
type DDSFuzzModel,
1112
type DDSFuzzSuiteOptions,
1213
} from "@fluid-private/test-dds-utils";
1314
import { FlushMode } from "@fluidframework/runtime-definitions/internal";
1415

1516
import type { SharedMatrixFactory } from "../runtime.js";
1617

17-
import { _dirname } from "./dirname.cjs";
1818
import { baseSharedMatrixModel, type Operation } from "./fuzz.js";
19+
import { SharedMatrixOracle, type IChannelWithOracles } from "./matrixOracle.js";
20+
21+
const oracleEmitter = new TypedEventEmitter<DDSFuzzHarnessEvents>();
22+
23+
oracleEmitter.on("clientCreate", (client) => {
24+
const channel = client.channel as IChannelWithOracles;
25+
const sharedMatrixOracle = new SharedMatrixOracle(channel);
26+
channel.matrixOracle = sharedMatrixOracle;
27+
registerOracle(sharedMatrixOracle);
28+
});
1929

2030
describe("Matrix fuzz tests", function () {
2131
/**
@@ -39,7 +49,7 @@ describe("Matrix fuzz tests", function () {
3949
clientAddProbability: 0.1,
4050
},
4151
reconnectProbability: 0,
42-
saveFailures: { directory: path.join(_dirname, "../../src/test/results") },
52+
emitter: oracleEmitter,
4353
};
4454

4555
const nameModel = (workloadName: string): DDSFuzzModel<SharedMatrixFactory, Operation> => ({
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/*!
2+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3+
* Licensed under the MIT License.
4+
*/
5+
6+
import { strict as assert } from "node:assert";
7+
8+
import type { IMatrixConsumer } from "@tiny-calc/nano";
9+
10+
import type { ISharedMatrix, SharedMatrix } from "../matrix.js";
11+
12+
import { TestConsumer } from "./testconsumer.js";
13+
14+
interface IConflict<T> {
15+
row: number;
16+
col: number;
17+
currentValue: T;
18+
conflictingValue: T;
19+
cellValue: T | undefined;
20+
lastEvent: "conflict" | "rowChange" | "colChange" | "cellChange";
21+
}
22+
23+
export class SharedMatrixOracle {
24+
private readonly conflictListener: (
25+
row: number,
26+
col: number,
27+
currentValue: unknown,
28+
conflictingValue: unknown,
29+
) => void;
30+
private latestConflict = new Map<string, IConflict<unknown>>();
31+
private readonly matrixConsumer: IMatrixConsumer<string>;
32+
private readonly testConsumer: TestConsumer;
33+
34+
constructor(private readonly shared: SharedMatrix) {
35+
this.matrixConsumer = {
36+
rowsChanged: (start, removed, inserted) => {
37+
this.updateLatestHistory("row", start, removed, inserted);
38+
},
39+
colsChanged: (start, removed, inserted) => {
40+
this.updateLatestHistory("col", start, removed, inserted);
41+
},
42+
cellsChanged: (
43+
rowStart: number,
44+
colStart: number,
45+
rowCount: number,
46+
colCount: number,
47+
) => {
48+
for (let r = rowStart; r < rowStart + rowCount; r++) {
49+
for (let c = colStart; c < colStart + colCount; c++) {
50+
const key = `${r},${c}`;
51+
const existing = this.latestConflict.get(key);
52+
53+
const cellValue = this.shared.getCell(r, c);
54+
55+
if (existing) {
56+
this.latestConflict.set(key, {
57+
...existing,
58+
cellValue, // capture current cell value
59+
lastEvent: "cellChange",
60+
});
61+
}
62+
}
63+
}
64+
},
65+
};
66+
67+
this.shared.openMatrix(this.matrixConsumer);
68+
this.testConsumer = new TestConsumer(this.shared);
69+
70+
this.conflictListener = (row, col, currentValue, conflictingValue) => {
71+
this.onConflict(row, col, currentValue, conflictingValue);
72+
};
73+
74+
if (this.shared.connected && this.shared.isSetCellConflictResolutionPolicyFWW()) {
75+
this.shared.on("conflict", this.conflictListener);
76+
}
77+
}
78+
79+
private updateLatestHistory(
80+
type: "row" | "col",
81+
start: number,
82+
removed: number,
83+
inserted: number,
84+
): void {
85+
const newMap = new Map<string, IConflict<unknown>>();
86+
87+
for (const [key, record] of this.latestConflict) {
88+
let keep = true;
89+
let { row, col } = record;
90+
91+
if (type === "row") {
92+
if (row >= start && row < start + removed) {
93+
keep = false; // row deleted, remove conflict
94+
} else if (row >= start + removed) {
95+
row += inserted - removed; // shift row
96+
}
97+
} else {
98+
if (col >= start && col < start + removed) {
99+
keep = false; // col deleted, remove conflict
100+
} else if (col >= start + removed) {
101+
col += inserted - removed; // shift col
102+
}
103+
}
104+
105+
if (keep) {
106+
const key = `${row},${col}`;
107+
newMap.set(key, {
108+
...record,
109+
row,
110+
col,
111+
lastEvent: type === "row" ? "rowChange" : "colChange",
112+
});
113+
}
114+
}
115+
this.latestConflict = newMap;
116+
}
117+
118+
private onConflict(
119+
row: number,
120+
col: number,
121+
currentValue: unknown,
122+
conflictingValue: unknown,
123+
): void {
124+
assert(
125+
this.shared.isSetCellConflictResolutionPolicyFWW(),
126+
"conflict event should only fire in FWW mode",
127+
);
128+
129+
// Only validate conflicts when the matrix is connected
130+
if (this.shared.connected && row < this.shared.rowCount && col < this.shared.colCount) {
131+
this.latestConflict.set(`${row},${col}`, {
132+
row,
133+
col,
134+
currentValue,
135+
conflictingValue,
136+
cellValue: this.testConsumer.getCell(row, col),
137+
lastEvent: "conflict",
138+
});
139+
}
140+
}
141+
142+
public validate(): void {
143+
// validate matrix
144+
for (let r = 0; r < this.shared.rowCount; r++) {
145+
for (let c = 0; c < this.shared.colCount; c++) {
146+
const expected = this.testConsumer.getCell(r, c);
147+
const actual = this.shared.getCell(r, c);
148+
assert.deepStrictEqual(actual, expected, `Mismatch at [${r},${c}]`);
149+
}
150+
}
151+
152+
// Validate conflict history
153+
for (const [, conflict] of this.latestConflict) {
154+
const { row, col, currentValue, conflictingValue, cellValue, lastEvent } = conflict;
155+
const inBounds = row < this.shared.rowCount && col < this.shared.colCount;
156+
const actual = inBounds ? this.shared.getCell(row, col) : undefined;
157+
158+
switch (lastEvent) {
159+
case "conflict": {
160+
// Probably cell is not yet set
161+
if (actual === undefined) continue;
162+
// Winner must be present in the matrix
163+
if (inBounds) {
164+
assert.deepStrictEqual(
165+
actual,
166+
currentValue,
167+
`Conflict mismatch at [${row},${col}]:
168+
expected winner=${currentValue},
169+
actual=${actual},
170+
loser=${conflictingValue} with cellValue=${cellValue}`,
171+
);
172+
}
173+
break;
174+
}
175+
case "rowChange":
176+
case "colChange": {
177+
// Just check entry is still valid
178+
assert.ok(
179+
inBounds,
180+
`Conflict entry at [${row},${col}] is out-of-bounds after ${lastEvent}`,
181+
);
182+
break;
183+
}
184+
case "cellChange": {
185+
// Ensure what we recorded matched the matrix state at the time
186+
if (inBounds) {
187+
assert.deepStrictEqual(
188+
actual,
189+
cellValue,
190+
`Cell change mismatch at [${row},${col}]:
191+
expected=${cellValue},
192+
actual=${actual}`,
193+
);
194+
}
195+
break;
196+
}
197+
default: {
198+
assert.fail(`Unexpected lastEvent type: ${lastEvent}`);
199+
}
200+
}
201+
}
202+
}
203+
204+
public dispose(): void {
205+
this.shared.off("conflict", this.conflictListener);
206+
this.shared.matrixProducer.closeMatrix(this.matrixConsumer);
207+
this.shared.matrixProducer.closeMatrix(this.testConsumer);
208+
}
209+
}
210+
211+
/**
212+
* @internal
213+
*/
214+
export interface IChannelWithOracles extends SharedMatrix {
215+
matrixOracle: SharedMatrixOracle;
216+
}
217+
218+
/**
219+
* Type guard for SharedMatrix with an oracle
220+
* @internal
221+
*/
222+
export function hasSharedMatrixOracle(s: ISharedMatrix): s is IChannelWithOracles {
223+
return "matrixOracle" in s && s.matrixOracle instanceof SharedMatrixOracle;
224+
}

0 commit comments

Comments
 (0)