From dfce20742d31da1f62acbbe28776e954df8054e8 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 28 Jan 2025 12:07:37 -0800 Subject: [PATCH 01/54] package scafolding --- .../local-server-stress-tests/.eslintrc.cjs | 27 + .../test/local-server-stress-tests/.gitignore | 52 + .../local-server-stress-tests/.mocharc.cjs | 12 + .../test/local-server-stress-tests/.npmignore | 7 + .../test/local-server-stress-tests/LICENSE | 21 + .../local-server-stress-tests/biome.jsonc | 4 + .../local-server-stress-tests/package.json | 116 ++ .../prettier.config.cjs | 8 + .../src/localServerStressHarness.ts | 1769 +++++++++++++++++ .../src/minification.ts | 250 +++ .../src/test/localServerStress.spec.ts | 4 + .../src/test/tsconfig.json | 10 + .../local-server-stress-tests/src/utils.ts | 107 + pnpm-lock.yaml | 132 ++ 14 files changed, 2519 insertions(+) create mode 100644 packages/test/local-server-stress-tests/.eslintrc.cjs create mode 100644 packages/test/local-server-stress-tests/.gitignore create mode 100644 packages/test/local-server-stress-tests/.mocharc.cjs create mode 100644 packages/test/local-server-stress-tests/.npmignore create mode 100644 packages/test/local-server-stress-tests/LICENSE create mode 100644 packages/test/local-server-stress-tests/biome.jsonc create mode 100644 packages/test/local-server-stress-tests/package.json create mode 100644 packages/test/local-server-stress-tests/prettier.config.cjs create mode 100644 packages/test/local-server-stress-tests/src/localServerStressHarness.ts create mode 100644 packages/test/local-server-stress-tests/src/minification.ts create mode 100644 packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts create mode 100644 packages/test/local-server-stress-tests/src/test/tsconfig.json create mode 100644 packages/test/local-server-stress-tests/src/utils.ts diff --git a/packages/test/local-server-stress-tests/.eslintrc.cjs b/packages/test/local-server-stress-tests/.eslintrc.cjs new file mode 100644 index 000000000000..623b6a0c58e3 --- /dev/null +++ b/packages/test/local-server-stress-tests/.eslintrc.cjs @@ -0,0 +1,27 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +module.exports = { + extends: [ + require.resolve("@fluidframework/eslint-config-fluid/minimal-deprecated"), + "prettier", + ], + rules: { + "@typescript-eslint/strict-boolean-expressions": "off", // requires strictNullChecks=true in tsconfig + "import/no-nodejs-modules": "off", + "@fluid-internal/fluid/no-unchecked-record-access": "warn", + "import/no-extraneous-dependencies": [ + "error", + { + // This package is only used to run its tests. It's ok for the src/utils.ts to import from devDependencies, in + // addition to the test files + devDependencies: ["src/test/**"], + }, + ], + }, + parserOptions: { + project: ["./src/test/tsconfig.json"], + }, +}; diff --git a/packages/test/local-server-stress-tests/.gitignore b/packages/test/local-server-stress-tests/.gitignore new file mode 100644 index 000000000000..ee26a5e7bdbf --- /dev/null +++ b/packages/test/local-server-stress-tests/.gitignore @@ -0,0 +1,52 @@ +# Compiled TypeScript and CSS +dist +lib + +# Babel +public/scripts/es5 + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt +.cache-loader + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- +node_modules + +# Typings +typings + +# Debug log from npm +npm-debug.log + +# Code coverage +nyc +.nyc_output/ + +# Chart dependencies +**/charts/*.tgz + +# Generated modules +intel_modules/ +temp_modules/ diff --git a/packages/test/local-server-stress-tests/.mocharc.cjs b/packages/test/local-server-stress-tests/.mocharc.cjs new file mode 100644 index 000000000000..cddbf0e44d55 --- /dev/null +++ b/packages/test/local-server-stress-tests/.mocharc.cjs @@ -0,0 +1,12 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +"use strict"; + +const getFluidTestMochaConfig = require("@fluid-internal/mocha-test-setup/mocharc-common"); + +const packageDir = __dirname; +const config = getFluidTestMochaConfig(packageDir); +module.exports = config; diff --git a/packages/test/local-server-stress-tests/.npmignore b/packages/test/local-server-stress-tests/.npmignore new file mode 100644 index 000000000000..f518002fc4dd --- /dev/null +++ b/packages/test/local-server-stress-tests/.npmignore @@ -0,0 +1,7 @@ +nyc +*.log +**/*.tsbuildinfo +src/test +dist/test +lib/test +**/_api-extractor-temp/** diff --git a/packages/test/local-server-stress-tests/LICENSE b/packages/test/local-server-stress-tests/LICENSE new file mode 100644 index 000000000000..60af0a6a40e9 --- /dev/null +++ b/packages/test/local-server-stress-tests/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation and contributors. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/test/local-server-stress-tests/biome.jsonc b/packages/test/local-server-stress-tests/biome.jsonc new file mode 100644 index 000000000000..4b65e1c0aea2 --- /dev/null +++ b/packages/test/local-server-stress-tests/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["../../../biome.jsonc"] +} diff --git a/packages/test/local-server-stress-tests/package.json b/packages/test/local-server-stress-tests/package.json new file mode 100644 index 000000000000..3dba45047173 --- /dev/null +++ b/packages/test/local-server-stress-tests/package.json @@ -0,0 +1,116 @@ +{ + "name": "@fluid-internal/local-server-stress-tests", + "version": "2.21.0", + "private": true, + "description": "Tests that can only run against the local server", + "homepage": "https://fluidframework.com", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/FluidFramework.git", + "directory": "packages/test/local-server-tests" + }, + "license": "MIT", + "author": "Microsoft and contributors", + "sideEffects": false, + "type": "commonjs", + "scripts": { + "build": "fluid-build . --task build", + "build:compile": "fluid-build . --task compile", + "build:test": "tsc --project ./src/test/tsconfig.json", + "check:biome": "biome check .", + "check:format": "npm run check:biome", + "check:prettier": "prettier --check . --cache --ignore-path ../../../.prettierignore", + "clean": "rimraf --glob dist lib \"**/*.tsbuildinfo\" \"**/*.build.log\" nyc", + "eslint": "eslint --format stylish src", + "eslint:fix": "eslint --format stylish src --fix --fix-type problem,suggestion,layout", + "format": "npm run format:biome", + "format:biome": "biome check . --write", + "format:prettier": "prettier --write . --cache --ignore-path ../../../.prettierignore", + "lint": "fluid-build . --task lint", + "lint:fix": "fluid-build . --task eslint:fix --task format", + "test": "npm run test:mocha", + "test:coverage": "c8 npm test", + "test:mocha": "mocha \"lib/test/**/*.spec.*js\" --exit", + "test:mocha:verbose": "cross-env FLUID_TEST_VERBOSE=1 npm run test:mocha" + }, + "c8": { + "all": true, + "cache-dir": "nyc/.cache", + "exclude": [ + "src/test/**/*.*ts", + "dist/test/**/*.*js", + "lib/test/**/*.*js" + ], + "exclude-after-remap": false, + "include": [ + "src/**/*.*ts", + "dist/**/*.*js", + "lib/**/*.*js" + ], + "report-dir": "nyc/report", + "reporter": [ + "cobertura", + "html", + "text" + ], + "temp-directory": "nyc/.nyc_output" + }, + "devDependencies": { + "@biomejs/biome": "~1.9.3", + "@fluid-experimental/tree": "workspace:~", + "@fluid-internal/mocha-test-setup": "workspace:~", + "@fluid-internal/client-utils": "workspace:~", + "@fluid-private/test-drivers": "workspace:~", + "@fluid-private/stochastic-test-utils": "workspace:~", + "@fluidframework/aqueduct": "workspace:~", + "@fluidframework/build-common": "^2.0.3", + "@fluidframework/build-tools": "^0.51.0", + "@fluidframework/container-definitions": "workspace:~", + "@fluidframework/container-loader": "workspace:~", + "@fluidframework/container-runtime": "workspace:~", + "@fluidframework/container-runtime-definitions": "workspace:~", + "@fluidframework/core-interfaces": "workspace:~", + "@fluidframework/core-utils": "workspace:~", + "@fluidframework/datastore": "workspace:~", + "@fluidframework/datastore-definitions": "workspace:~", + "@fluidframework/driver-definitions": "workspace:~", + "@fluidframework/driver-utils": "workspace:~", + "@fluidframework/eslint-config-fluid": "^5.6.0", + "@fluidframework/id-compressor": "workspace:~", + "@fluidframework/local-driver": "workspace:~", + "@fluidframework/map": "workspace:~", + "@fluidframework/runtime-definitions": "workspace:~", + "@fluidframework/runtime-utils": "workspace:~", + "@fluidframework/sequence": "workspace:~", + "@fluidframework/server-local-server": "^5.0.0", + "@fluidframework/telemetry-utils": "workspace:~", + "@fluidframework/test-utils": "workspace:~", + "@fluidframework/tree": "workspace:~", + "@types/mocha": "^10.0.10", + "@types/node": "^18.19.0", + "@types/uuid": "^9.0.2", + "c8": "^8.0.1", + "cross-env": "^7.0.3", + "eslint": "~8.55.0", + "mocha": "^10.2.0", + "mocha-multi-reporters": "^1.5.1", + "prettier": "~3.0.3", + "rimraf": "^4.4.0", + "ts-loader": "^9.5.1", + "typescript": "~5.4.5", + "uuid": "^9.0.0" + }, + "fluidBuild": { + "tasks": { + "build:test": [ + "^tsc", + "^api-extractor:commonjs" + ] + } + }, + "typeValidation": { + "disabled": true, + "broken": {}, + "entrypoint": "internal" + } +} diff --git a/packages/test/local-server-stress-tests/prettier.config.cjs b/packages/test/local-server-stress-tests/prettier.config.cjs new file mode 100644 index 000000000000..d4870022599f --- /dev/null +++ b/packages/test/local-server-stress-tests/prettier.config.cjs @@ -0,0 +1,8 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +module.exports = { + ...require("@fluidframework/build-common/prettier.config.cjs"), +}; diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts new file mode 100644 index 000000000000..4ac3557f0dff --- /dev/null +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -0,0 +1,1769 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; +import { mkdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { TypedEventEmitter } from "@fluid-internal/client-utils"; +import type { + AsyncGenerator, + AsyncReducer, + BaseFuzzTestState, + IRandom, + SaveDestination, + SaveInfo, +} from "@fluid-private/stochastic-test-utils"; +import { + ExitBehavior, + StressMode, + asyncGeneratorFromArray, + chainAsync, + createFuzzDescribe, + createWeightedAsyncGenerator, + defaultOptions, + done, + interleaveAsync, + makeRandom, + performFuzzActionsAsync, + saveOpsToFile, + takeAsync, +} from "@fluid-private/stochastic-test-utils"; +import { AttachState } from "@fluidframework/container-definitions"; +import type { IFluidHandle } from "@fluidframework/core-interfaces"; +import { unreachableCase } from "@fluidframework/core-utils/internal"; +import type { + IChannelFactory, + IChannelServices, +} from "@fluidframework/datastore-definitions/internal"; +import type { IIdCompressor } from "@fluidframework/id-compressor"; +import type { IIdCompressorCore } from "@fluidframework/id-compressor/internal"; +import { FluidSerializer } from "@fluidframework/shared-object-base/internal"; +import { + MockContainerRuntimeFactoryForReconnection, + MockFluidDataStoreRuntime, + MockStorage, +} from "@fluidframework/test-runtime-utils/internal"; +import type { IMockContainerRuntimeOptions } from "@fluidframework/test-runtime-utils/internal"; +import { v4 as uuid } from "uuid"; + +import { + type Client, + type ClientLoadData, + type ClientWithStashData, + type FuzzSerializedIdCompressor, + createLoadData, + createLoadDataFromStashData, + hasStashData, +} from "./clientLoading.js"; +import { DDSFuzzHandle } from "./ddsFuzzHandle.js"; +import type { MinimizationTransform } from "./minification.js"; +import { FuzzTestMinimizer } from "./minification.js"; + +const isOperationType = ( + type: O["type"], + op: BaseOperation, +): op is O => op.type === type; + +/** + * @internal + */ +export interface DDSRandom extends IRandom { + handle(): IFluidHandle; +} + +/** + * @internal + */ +export interface DDSFuzzTestState + extends BaseFuzzTestState { + containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection; + + random: DDSRandom; + + /** + * Client which is responsible for summarizing. This client remains connected and read-only + * throughout the test. + * + * This client is also used for consistency validation, as eventual consistency bugs are + * typically easier to reason about when one client was readonly. + */ + summarizerClient: Client; + clients: Client[]; + // Client which was selected to perform an operation on + client: Client; + isDetached: boolean; +} + +/** + * @internal + */ +export interface ClientSpec { + clientId: string; +} + +/** + * @internal + */ +export interface BaseOperation { + type: number | string; +} + +/** + * @internal + */ +export interface ChangeConnectionState { + type: "changeConnectionState"; + connected: boolean; +} + +/** + * @internal + */ +export interface StashClient { + type: "stashClient"; + existingClientId: string; + newClientId: string; +} + +/** + * @internal + */ +export interface HandlePicked { + type: "handlePicked"; + handleId: string; +} + +/** + * @internal + */ +export interface Attach { + type: "attach"; +} + +/** + * @internal + */ +export interface Attaching { + type: "attaching"; + beforeRehydrate?: true; +} + +/** + * @internal + */ +export interface Rehydrate { + type: "rehydrate"; +} + +/** + * @internal + */ +export interface TriggerRebase { + type: "rebase"; +} + +/** + * @internal + */ +export interface AddClient { + type: "addClient"; + addedClientId: string; + canBeStashed: boolean; +} + +/** + * @internal + */ +export interface Synchronize { + type: "synchronize"; + clients?: string[]; +} + +/** + * @internal + */ +interface HasWorkloadName { + workloadName: string; +} + +function getSaveDirectory(directory: string, model: HasWorkloadName): string { + const workloadFriendly = model.workloadName.replace(/[\s_]+/g, "-").toLowerCase(); + return path.join(directory, workloadFriendly); +} + +function getSavePath(directory: string, model: HasWorkloadName, seed: number): string { + return path.join(getSaveDirectory(directory, model), `${seed}.json`); +} + +function getSaveInfo( + model: HasWorkloadName, + options: DDSFuzzSuiteOptions, + seed: number, +): SaveInfo { + return { + saveOnFailure: options.saveFailures + ? { path: getSavePath(options.saveFailures.directory, model, seed) } + : false, + saveOnSuccess: options.saveSuccesses + ? { path: getSavePath(options.saveSuccesses.directory, model, seed) } + : false, + }; +} + +/** + * Represents a generic fuzz model for testing eventual consistency of a DDS. + * + * @remarks + * + * Typical DDSes will parameterize this with their SharedObject factory and a serializable set + * of operations corresponding to valid edits in the DDS's public API. + * + * @example + * A simplified SharedString data structure exposing the APIs `insertAt(index, contentString)` and `removeRange(start, end)` + * might represent their API with the following operations: + * ```typescript + * type InsertOperation = { type: "insert"; index: number; content: string } + * type RemoveOperation = { type: "remove"; start: number; end: number } + * type Operation = InsertOperation | RemoveOperation; + * ``` + * + * It would then typically use utilities from \@fluid-private/stochastic-test-utils to write a generator + * for inserting/removing content, and a reducer for interpreting the serializable operations in terms of + * SimpleSharedString's public API. + * + * See \@fluid-private/stochastic-test-utils's README for more details on this step. + * + * Then, it could define a model like so: + * ```typescript + * const model: DDSFuzzModel = { + * workloadName: "insert and delete", + * factory: SimpleSharedStringFactory, + * generatorFactory: myGeneratorFactory, + * reducer: myReducer, + * // A non-toy implementation would typically give a more informative assertion error (e.g. including + * // the IDs for `a` and `b`). + * validateConsistency: (a, b) => { assert.equal(a.channel.getText(), b.channel.getText()); } + * } + * ``` + * This model can be used directly to create a suite of fuzz tests with {@link (createDDSFuzzSuite:function)} + * + * @internal + */ +export interface DDSFuzzModel< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, + TState extends DDSFuzzTestState = DDSFuzzTestState, +> { + /** + * Name for this model. This is used for test case naming, and should generally reflect properties + * about the kinds of operations that are generated. + * For example, SharedString might fuzz test several different workloads--some involving intervals, + * some without, some that never delete text, etc. + * This name should also be relatively friendly for file system; if the "save to disk" option of + * {@link (createDDSFuzzSuite:function)} is enabled, it will be kebab cased for failure files. + */ + workloadName: string; + + /** + * ChannelFactory to instantiate the DDS. + */ + factory: TChannelFactory; + + /** + * Factory which creates a generator for this model. + * @remarks DDS model generators can decide to use the "channel" or "client" field to decide which + * client to perform the operation on. + */ + generatorFactory: () => AsyncGenerator; + + /** + * Reducer capable of updating the test state according to the operations generated. + */ + reducer: AsyncReducer; + + /** + * Equivalence validation function, which should verify that the provided channels contain the same data. + * This is run at each synchronization point for all connected clients (as disconnected clients won't + * necessarily have the same set of ops applied). + * @throws - An informative error if the channels don't have equivalent data. + */ + validateConsistency: ( + channelA: Client, + channelB: Client, + ) => void | Promise; + + /** + * An array of transforms used during fuzz test minimization to reduce test + * cases. See {@link MinimizationTransform} for additional context. + * + * If no transforms are supplied, minimization will still occur, but the + * contents of the operations will remain unchanged. + */ + minimizationTransforms?: MinimizationTransform[]; +} + +/** + * @internal + */ +export interface DDSFuzzHarnessEvents { + /** + * Raised for each non-summarizer client created during fuzz test execution. + */ + (event: "clientCreate", listener: (client: Client) => void); + + /** + * Raised after creating the initialState but prior to performing the fuzzActions.. + */ + (event: "testStart", listener: (initialState: DDSFuzzTestState) => void); + + /** + * Raised after all fuzzActions have been completed. + */ + (event: "testEnd", listener: (finalState: DDSFuzzTestState) => void); + + /** + * Raised before each generated operation is run by its reducer. + */ + (event: "operationStart", listener: (operation: BaseOperation) => void); +} + +/** + * @internal + */ +export interface DDSFuzzSuiteOptions { + /** + * Number of tests to generate for correctness modes (which are run in the PR gate). + */ + defaultTestCount: number; + + /** + * Number of clients to perform operations on following the attach phase. + * This does not include the read-only client created for consistency validation + * and summarization--see {@link DDSFuzzTestState.summarizerClient}. + * + * See {@link DDSFuzzSuiteOptions.detachedStartOptions} for more details on the detached start phase. + * See {@link DDSFuzzSuiteOptions.clientJoinOptions} for more details on clients joining after those in the initial attach. + */ + numberOfClients: number; + + /** + * Options dictating if and when to simulate new clients joining the collaboration session. + * If not specified, no new clients will be added after the test starts. + * + * This option is useful for testing eventual consistency bugs related to summarization. + * + * @remarks Even without enabling this option, DDS fuzz models can generate {@link AddClient} + * operations with whatever strategy is appropriate. + * This is useful for nudging test cases towards a particular pattern of clients joining. + */ + clientJoinOptions?: { + /** + * The maximum number of clients that will ever be added to the test. + * @remarks Due to current mock limitations, clients will only ever be added to the collaboration session, + * not removed. + * Adding an excessive number of clients may cause performance issues. + */ + maxNumberOfClients: number; + + /** + * The probability that a client will be added at any given operation. + * If the current number of clients has reached the maximum, this probability is ignored. + */ + clientAddProbability: number; + /** + * The probability for an added client to also be stashable which simulates + * getting the pending state, closing the container, and re-opening with the state. + */ + stashableClientProbability?: number; + }; + + /** + * Dictates simulation of edits made to a DDS while that DDS is detached. + * + * When enabled, the fuzz test starts with a single client generating edits. After a certain number of ops (dictated by `numOpsBeforeAttach`), + * an attach op will be generated, at which point: + * - getAttachSummary will be invoked on this client + * - The remaining clients (as dictated by {@link DDSFuzzSuiteOptions.numberOfClients}) will load from this summary and join the session + * + * This setup simulates application code initializing state in a data store before attaching it, e.g. running code to edit a DDS from + * `DataObject.initializingFirstTime`. + * Default: tests are run with this setting enabled, with 5 ops being generated before an attach op. A new client is also rehydrated from + * summary. To disable the generation of rehydrate ops, set `rehydrateDisabled` to `true`. + */ + detachedStartOptions: { + numOpsBeforeAttach: number; + rehydrateDisabled?: true; + attachingBeforeRehydrateDisable?: true; + }; + + /** + * Defines whether or not ops can be submitted with handles. + */ + handleGenerationDisabled: boolean; + + /** + * Event emitter which allows hooking into interesting points of DDS harness execution. + * Test authors that want to subscribe to any of these events should create a `TypedEventEmitter`, + * do so, and pass it in when creating the suite. + * + * @example + * + * ```typescript + * const emitter = new TypedEventEmitter(); + * emitter.on("clientCreate", (client) => { + * // Casting is necessary as the event typing isn't parameterized with each DDS type. + * const myDDS = client.channel as MyDDSType; + * // Do what you want with `myDDS`, e.g. subscribe to change events, add logging, etc. + * }); + * const options = { + * ...defaultDDSFuzzSuiteOptions, + * emitter, + * }; + * createDDSFuzzSuite(model, options); + * ``` + */ + emitter: TypedEventEmitter; + + /** + * Strategy for validating eventual consistency of DDSes. + * In random mode, each generated operation has the specified probability to instead be a synchronization point + * (all connected clients process all ops) followed by validation that all clients agree on their shared state. + * In fixed interval mode, this synchronization happens on a predictable cadence: every `interval` operations + * generated. + */ + validationStrategy: + | { type: "random"; probability: number } + | { type: "fixedInterval"; interval: number } + // WIP: This validation strategy still currently synchronizes all clients. + | { type: "partialSynchronization"; probability: number; clientProbability: number }; + parseOperations: (serialized: string) => BaseOperation[]; + + /** + * Each non-synchronization option has this probability of instead generating a disconnect/reconnect. + * The reconnect operation currently *replaces* the operation generated by the model's generator. + * + * TODO: Expose options for how to inject reconnection in a more flexible way. + */ + reconnectProbability: number; + + /** + * Each non-synchronization option has this probability of rebasing the current batch before sending it. + */ + rebaseProbability: number; + + /** + * Seed which should be replayed from disk. + * + * This option is intended for quick, by-hand minimization of failure JSON. As such, it adds a `.only` + * to the corresponding replay test. + * + * TODO: Improving workflows around fuzz test minimization, regression test generation for a particular seed, + * or more flexibility around replay of test files would be a nice value add to this harness. + */ + replay?: number; + + /** + * Runs only the provided seeds. + * + * @example + * + * ```typescript + * // Runs only seed 42 for the given model. + * createDDSFuzzSuite(model, { only: [42] }); + * ``` + * + * @remarks + * If you prefer, a variant of the standard `.only` syntax works. See {@link (createDDSFuzzSuite:namespace).only}. + */ + only: Iterable; + + /** + * Skips the provided seeds. + * + * @example + * + * ```typescript + * // Skips seed 42 for the given model. + * createDDSFuzzSuite(model, { skip: [42] }); + * ``` + * + * @remarks + * If you prefer, a variant of the standard `.skip` syntax works. See {@link (createDDSFuzzSuite:namespace).skip}. + */ + skip: Iterable; + + /** + * Whether failure files should be saved to disk, and if so, the directory in which they should be saved. + * Each seed will be saved in a subfolder of this directory obtained by kebab-casing the model name. + * + * Turning on this feature is encouraged for quick minimization. + */ + saveFailures: false | { directory: string }; + + /** + * Whether successful runs should be saved to disk and where. + * Minimization will be skipped for these files. + * + * This feature is useful to audit the scenarios generated by a given fuzz configuration. + */ + saveSuccesses: false | { directory: string }; + + /** + * Options to be provided to the underlying container runtimes {@link @fluidframework/test-runtime-utils#IMockContainerRuntimeOptions}. + * By default nothing will be provided, which means that the runtimes will: + * - use FlushMode.Immediate, which means that all ops will be sent as soon as they are produced, + * therefore all batches have a single op. + * - not use grouped batching. + */ + containerRuntimeOptions?: IMockContainerRuntimeOptions; + + /** + * Whether or not to skip minimization of fuzz failing test cases. This is useful + * when one only cares about the counts or types of errors, and not the + * exact contents of the test cases. + * + * Minimization only works when the failure occurs as part of a reducer, and is mostly + * useful if the model being tested defines {@link DDSFuzzModel.minimizationTransforms}. + * + * It can also add a couple seconds of overhead per failing + * test case. See {@link MinimizationTransform} for additional context. + */ + skipMinimization?: boolean; + + /** + * An optional IdCompressor that will be passed to the constructed MockDataStoreRuntime instance. + */ + idCompressorFactory?: ( + summary?: FuzzSerializedIdCompressor, + ) => IIdCompressor & IIdCompressorCore; +} + +/** + * @internal + */ +export const defaultDDSFuzzSuiteOptions: DDSFuzzSuiteOptions = { + defaultTestCount: defaultOptions.defaultTestCount, + detachedStartOptions: { + numOpsBeforeAttach: 5, + }, + handleGenerationDisabled: true, + emitter: new TypedEventEmitter(), + numberOfClients: 3, + only: [], + skip: [], + parseOperations: (serialized: string) => JSON.parse(serialized) as BaseOperation[], + reconnectProbability: 0, + rebaseProbability: 0, + saveFailures: false, + saveSuccesses: false, + validationStrategy: { type: "random", probability: 0.05 }, +}; + +/** + * Mixes in functionality to add new clients to a DDS fuzz model. + * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to + * expose at the package level if we want to expose some of the harness's building blocks. + */ +export function mixinNewClient< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, + TState extends DDSFuzzTestState, +>( + model: DDSFuzzModel, + options: DDSFuzzSuiteOptions, +): DDSFuzzModel { + const isClientAddOp = (op: TOperation | AddClient): op is AddClient => + op.type === "addClient"; + + const generatorFactory: () => AsyncGenerator = () => { + const baseGenerator = model.generatorFactory(); + return async (state: TState): Promise => { + const baseOp = baseGenerator(state); + const { clients, random, isDetached } = state; + if ( + options.clientJoinOptions !== undefined && + clients.length < options.clientJoinOptions.maxNumberOfClients && + !isDetached && + random.bool(options.clientJoinOptions.clientAddProbability) + ) { + return { + type: "addClient", + addedClientId: makeFriendlyClientId(random, clients.length), + canBeStashed: options.clientJoinOptions?.stashableClientProbability + ? random.bool(options.clientJoinOptions.stashableClientProbability) + : false, + }; + } + return baseOp; + }; + }; + + const minimizationTransforms: MinimizationTransform[] = + (model.minimizationTransforms as + | MinimizationTransform[] + | undefined) ?? []; + + minimizationTransforms.push((op: TOperation | AddClient): void => { + if (isClientAddOp(op)) { + op.canBeStashed = false; + } + }); + + const reducer: AsyncReducer = async (state, op) => { + if (isClientAddOp(op)) { + const newClient = await loadClient( + state.containerRuntimeFactory, + state.summarizerClient, + model.factory, + op.addedClientId, + options, + op.canBeStashed, + ); + state.clients.push(newClient); + return state; + } + return model.reducer(state, op); + }; + + return { + ...model, + minimizationTransforms, + generatorFactory, + reducer, + }; +} + +/** + * Mixes in functionality to disconnect and reconnect clients in a DDS fuzz model. + * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to + * expose at the package level if we want to expose some of the harness's building blocks. + */ +export function mixinReconnect< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, + TState extends DDSFuzzTestState, +>( + model: DDSFuzzModel, + options: DDSFuzzSuiteOptions, +): DDSFuzzModel { + const generatorFactory: () => AsyncGenerator = + () => { + const baseGenerator = model.generatorFactory(); + return async (state): Promise => { + const baseOp = baseGenerator(state); + if (!state.isDetached && state.random.bool(options.reconnectProbability)) { + const client = state.clients.find((c) => c.channel.id === state.client.channel.id); + assert(client !== undefined); + return { + type: "changeConnectionState", + connected: !client.containerRuntime.connected, + }; + } + + return baseOp; + }; + }; + + const minimizationTransforms = model.minimizationTransforms as + | MinimizationTransform[] + | undefined; + + const reducer: AsyncReducer = async ( + state, + operation, + ) => { + if (operation.type === "changeConnectionState") { + state.client.containerRuntime.connected = (operation as ChangeConnectionState).connected; + return state; + } else { + return model.reducer(state, operation as TOperation); + } + }; + return { + ...model, + minimizationTransforms, + generatorFactory, + reducer, + }; +} + +/** + * Mixes in functionality to generate an 'attach' op, which + * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to + * expose at the package level if we want to expose some of the harness's building blocks. + */ +export function mixinAttach< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, + TState extends DDSFuzzTestState, +>( + model: DDSFuzzModel, + options: DDSFuzzSuiteOptions, +): DDSFuzzModel { + const { numOpsBeforeAttach, rehydrateDisabled, attachingBeforeRehydrateDisable } = + options.detachedStartOptions; + if (numOpsBeforeAttach === 0) { + // not wrapping the reducer/generator in this case makes stepping through the harness slightly less painful. + return model as DDSFuzzModel< + TChannelFactory, + TOperation | Attach | Attaching | Rehydrate, + TState + >; + } + const attachOp = async (): Promise => { + return { type: "attach" }; + }; + const rehydrateOp = async (): Promise => { + return { type: "rehydrate" }; + }; + const generatorFactory: () => AsyncGenerator< + TOperation | Attach | Attaching | Rehydrate, + TState + > = () => { + const baseGenerator = model.generatorFactory(); + const rehydrates = rehydrateDisabled + ? [] + : [ + // sometimes mix a single attaching op + // in before rehydrate so we test + // applying stashed ops while detached + createWeightedAsyncGenerator([ + [takeAsync(numOpsBeforeAttach, baseGenerator), numOpsBeforeAttach], + [ + takeAsync( + 1, + async (): Promise => ({ + type: "attaching", + beforeRehydrate: true, + }), + ), + attachingBeforeRehydrateDisable === true ? 0 : 1, + ], + ]), + takeAsync(1, rehydrateOp), + ]; + return chainAsync( + ...rehydrates, + takeAsync(numOpsBeforeAttach, baseGenerator), + takeAsync(1, attachOp), + baseGenerator, + ); + }; + + const minimizationTransforms = model.minimizationTransforms as + | MinimizationTransform[] + | undefined; + + const reducer: AsyncReducer = async ( + state, + operation, + ) => { + if (isOperationType("attach", operation)) { + state.isDetached = false; + assert.equal(state.clients.length, 1); + const clientA: ClientWithStashData = state.clients[0]; + finalizeAllocatedIds(clientA); + clientA.dataStoreRuntime.setAttachState(AttachState.Attached); + const services: IChannelServices = { + deltaConnection: clientA.dataStoreRuntime.createDeltaConnection(), + objectStorage: new MockStorage(), + }; + clientA.channel.connect(services); + const clients: Client[] = await Promise.all( + Array.from({ length: options.numberOfClients }, async (_, index) => + loadClient( + state.containerRuntimeFactory, + clientA, + model.factory, + index === 0 ? "summarizer" : makeFriendlyClientId(state.random, index), + options, + index !== 0 && options.clientJoinOptions?.stashableClientProbability + ? state.random.bool(options.clientJoinOptions.stashableClientProbability) + : false, + ), + ), + ); + // eslint-disable-next-line require-atomic-updates + clientA.stashData = undefined; + + // While detached, the initial state was set up so that the 'summarizer client' was the same as the detached client. + // This is actually a pretty reasonable representation of what really happens. + // However, now that we're transitioning to an attached state, the summarizer client should never have any edits. + // Thus we use one of the clients we just loaded as the summarizer client, and keep the client around that we generated the + // attach summary from. + const summarizerClient: Client = clients[0]; + clients[0] = state.clients[0]; + + return { + ...state, + isDetached: false, + clients, + summarizerClient, + }; + } else if (isOperationType("rehydrate", operation)) { + const clientA = state.clients[0]; + assert.equal(state.clients.length, 1); + + state.containerRuntimeFactory.removeContainerRuntime(clientA.containerRuntime); + + const summarizerClient = await loadDetached( + state.containerRuntimeFactory, + clientA, + model.factory, + makeFriendlyClientId(state.random, 0), + options, + ); + + await model.validateConsistency(clientA, summarizerClient); + + return { + ...state, + isDetached: true, + clients: [summarizerClient], + summarizerClient, + }; + } else if (isOperationType("attaching", operation)) { + assert.equal(state.clients.length, 1); + const clientA: ClientWithStashData = state.clients[0]; + finalizeAllocatedIds(clientA); + + if (operation.beforeRehydrate === true) { + clientA.stashData = createLoadData(clientA, true); + } + clientA.dataStoreRuntime.setAttachState(AttachState.Attaching); + const services: IChannelServices = { + deltaConnection: clientA.dataStoreRuntime.createDeltaConnection(), + objectStorage: new MockStorage(), + }; + clientA.channel.connect(services); + + return state; + } + return model.reducer(state, operation); + }; + return { + ...model, + minimizationTransforms, + generatorFactory, + reducer, + }; +} + +/** + * Mixes in functionality to rebase in-flight batches in a DDS fuzz model. A batch is rebased by + * resending it to the datastores before being sent over the wire. + * + * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to + * expose at the package level if we want to expose some of the harness's building blocks. + */ +export function mixinRebase< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, + TState extends DDSFuzzTestState, +>( + model: DDSFuzzModel, + options: DDSFuzzSuiteOptions, +): DDSFuzzModel { + const generatorFactory: () => AsyncGenerator = () => { + const baseGenerator = model.generatorFactory(); + return async (state): Promise => { + const baseOp = baseGenerator(state); + if (state.random.bool(options.rebaseProbability)) { + const client = state.clients.find((c) => c.channel.id === state.client.channel.id); + assert(client !== undefined); + return { + type: "rebase", + }; + } + + return baseOp; + }; + }; + + const minimizationTransforms = model.minimizationTransforms as + | MinimizationTransform[] + | undefined; + + const reducer: AsyncReducer = async ( + state, + operation, + ) => { + if (isOperationType("rebase", operation)) { + assert( + state.client.containerRuntime.rebase !== undefined, + "Unsupported mock runtime version", + ); + state.client.containerRuntime.rebase(); + return state; + } else { + return model.reducer(state, operation); + } + }; + return { + ...model, + minimizationTransforms, + generatorFactory, + reducer, + }; +} + +/** + * Mixes in functionality to generate ops which synchronize all clients and assert the resulting state is consistent. + * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to + * expose at the package level if we want to expose some of the harness's building blocks. + */ +export function mixinSynchronization< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, + TState extends DDSFuzzTestState, +>( + model: DDSFuzzModel, + options: DDSFuzzSuiteOptions, +): DDSFuzzModel { + const { validationStrategy } = options; + let generatorFactory: () => AsyncGenerator; + + switch (validationStrategy.type) { + case "random": { + // passing 1 here causes infinite loops. passing close to 1 is wasteful + // as synchronization + eventual consistency validation should be idempotent. + // 0.5 is arbitrary but there's no reason anyone should want a probability near this. + assert(validationStrategy.probability < 0.5, "Use a lower synchronization probability."); + generatorFactory = (): AsyncGenerator => { + const baseGenerator = model.generatorFactory(); + return async (state: TState): Promise => + !state.isDetached && state.random.bool(validationStrategy.probability) + ? { type: "synchronize" } + : baseGenerator(state); + }; + break; + } + + case "fixedInterval": { + generatorFactory = (): AsyncGenerator => { + const baseGenerator = model.generatorFactory(); + return interleaveAsync( + baseGenerator, + async (state) => + state.isDetached ? baseGenerator(state) : ({ type: "synchronize" } as const), + validationStrategy.interval, + 1, + ExitBehavior.OnEitherExhausted, + ); + }; + break; + } + + case "partialSynchronization": { + // passing 1 here causes infinite loops. passing close to 1 is wasteful + // as synchronization + eventual consistency validation should be idempotent. + // 0.5 is arbitrary but there's no reason anyone should want a probability near this. + assert(validationStrategy.probability < 0.5, "Use a lower synchronization probability."); + generatorFactory = (): AsyncGenerator => { + const baseGenerator = model.generatorFactory(); + return async (state: TState): Promise => { + if (!state.isDetached && state.random.bool(validationStrategy.probability)) { + const selectedClients = new Set( + state.clients + .filter((client) => client.containerRuntime.connected) + .filter(() => state.random.bool(validationStrategy.clientProbability)) + .map((client) => client.channel.id), + ); + + return { type: "synchronize", clients: [...selectedClients] }; + } else { + return baseGenerator(state); + } + }; + }; + break; + } + default: { + unreachableCase(validationStrategy); + } + } + + const minimizationTransforms = model.minimizationTransforms as + | MinimizationTransform[] + | undefined; + + const isSynchronizeOp = (op: BaseOperation): op is Synchronize => op.type === "synchronize"; + const reducer: AsyncReducer = async (state, operation) => { + // TODO: Only synchronize listed clients if specified + if (isSynchronizeOp(operation)) { + const connectedClients = state.clients.filter( + (client) => client.containerRuntime.connected, + ); + + for (const client of connectedClients) { + assert( + client.containerRuntime.flush !== undefined, + "Unsupported mock runtime version", + ); + client.containerRuntime.flush(); + } + + state.containerRuntimeFactory.processAllMessages(); + if (connectedClients.length > 0) { + const readonlyChannel = state.summarizerClient; + for (const client of connectedClients) { + try { + await model.validateConsistency(readonlyChannel, client); + } catch (error: unknown) { + if (error instanceof Error) { + error.message = `Comparing client ${readonlyChannel.channel.id} vs client ${client.channel.id}\n${error.message}`; + } + throw error; + } + } + } + + return state; + } + return model.reducer(state, operation); + }; + return { + ...model, + minimizationTransforms, + generatorFactory, + reducer, + }; +} + +const isClientSpec = (op: unknown): op is ClientSpec => + (op as ClientSpec).clientId !== undefined; + +/** + * Mixes in the ability to select a client to perform an operation on. + * Makes this available to existing generators and reducers in the passed-in model via {@link DDSFuzzTestState.client} + * and {@link @fluid-private/test-dds-utils#DDSFuzzTestState.channel}. + * + * @remarks This exists purely for convenience, as "pick a client to perform an operation on" is a common concern. + * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to + * expose at the package level if we want to expose some of the harness's building blocks. + */ +export function mixinClientSelection< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, + TState extends DDSFuzzTestState, +>( + model: DDSFuzzModel, + _: DDSFuzzSuiteOptions, +): DDSFuzzModel { + const generatorFactory: () => AsyncGenerator = () => { + const baseGenerator = model.generatorFactory(); + return async (state): Promise => { + // Pick a channel, and: + // 1. Make it available for the DDS model generators (so they don't need to + // do the boilerplate of selecting a client to perform the operation on) + // 2. Make it available to the subsequent reducer logic we're going to inject + // (so that we can recover the channel from serialized data) + const client = state.random.pick(state.clients); + const baseOp = await runInStateWithClient(state, client, async () => + baseGenerator(state), + ); + return baseOp === done + ? done + : { + ...baseOp, + clientId: client.channel.id, + }; + }; + }; + + const reducer: AsyncReducer = async (state, operation) => { + assert(isClientSpec(operation), "operation should have been given a client"); + const client = state.clients.find((c) => c.channel.id === operation.clientId); + assert(client !== undefined); + await runInStateWithClient(state, client, async () => + model.reducer(state, operation as TOperation), + ); + }; + return { + ...model, + generatorFactory, + reducer, + }; +} + +export function mixinStashedClient< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, + TState extends DDSFuzzTestState, +>( + model: DDSFuzzModel, + options: DDSFuzzSuiteOptions, +): DDSFuzzModel { + if (options.clientJoinOptions?.stashableClientProbability === undefined) { + return model as DDSFuzzModel; + } + + const generatorFactory: () => AsyncGenerator = () => { + const baseGenerator = model.generatorFactory(); + return async (state): Promise => { + const stashable = state.clients.filter( + (c) => hasStashData(c) && c.containerRuntime.isDirty, + ); + + if (!state.isDetached && stashable.length > 0 && state.random.bool(0.5)) { + const existingClientId = state.random.pick(stashable).channel.id; + const instanceIndex = existingClientId.lastIndexOf("_"); + const instance = + instanceIndex < 0 + ? 0 + : Number.parseInt(existingClientId.slice(instanceIndex + 1), 10); + return { + type: "stashClient", + existingClientId, + newClientId: `${existingClientId}_${instance + 1}`, + }; + } + return baseGenerator(state); + }; + }; + + const reducer: AsyncReducer = async (state, operation) => { + const { clients, containerRuntimeFactory } = state; + if (isOperationType("stashClient", operation)) { + const client = clients.find((c) => c.channel.id === operation.existingClientId); + if (!hasStashData(client)) { + throw new ReducerPreconditionError("client not stashable"); + } + const loadData = createLoadDataFromStashData(client, client.stashData); + + // load a new client from the same state as the original client + const newClient = await loadClientFromSummaries( + containerRuntimeFactory, + loadData, + model.factory, + operation.newClientId, + options, + ); + + await newClient.containerRuntime.initializeWithStashedOps(client.containerRuntime); + + // replace the old client with the new client + return { + ...state, + clients: [...clients.filter((c) => c.channel.id !== client.channel.id), newClient], + }; + } + + return model.reducer(state, operation); + }; + + return { + ...model, + generatorFactory, + reducer, + minimizationTransforms: model.minimizationTransforms as MinimizationTransform< + TOperation | StashClient + >[], + }; +} + +/** + * This modifies the value of "client" while callback is running, then restores it. + * This is does instead of copying the state since the state object is mutable, and running callback might make changes to state (like add new members) which are lost if state is just copied. + * + * Since the callback is async, this modification to the state could be an issue if multiple runs of this function are done concurrently. + */ +async function runInStateWithClient, Result>( + state: TState, + client: TState["client"], + callback: (state: TState) => Promise, +): Promise { + const oldClient = state.client; + state.client = client; + try { + return await callback(state); + } finally { + // This code is explicitly trying to "update" to the old value. + // eslint-disable-next-line require-atomic-updates + state.client = oldClient; + } +} + +function makeUnreachableCodePathProxy(name: string): T { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return new Proxy({} as T, { + get: (): never => { + throw new Error( + `Unexpected read of '${name}:' this indicates a bug in the DDS eventual consistency harness.`, + ); + }, + }); +} + +function createDetachedClient( + containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection, + factory: TChannelFactory, + clientId: string, + options: Omit, +): Client { + const dataStoreRuntime = new MockFluidDataStoreRuntime({ + clientId, + idCompressor: + options.idCompressorFactory === undefined ? undefined : options.idCompressorFactory(), + attachState: AttachState.Detached, + }); + // Note: we re-use the clientId for the channel id here despite connecting all clients to the same channel: + // this isn't how it would work in a real scenario, but the mocks don't use the channel id for any message + // routing behavior and making all of the object ids consistent helps with debugging and writing more informative + // consistency validation. + const channel: ReturnType = factory.create( + dataStoreRuntime, + clientId, + ); + + const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime, { + // only track remote ops(which enables initialize from stashed ops), if rehydrate is enabled + trackRemoteOps: options.detachedStartOptions.rehydrateDisabled !== true, + }); + // TS resolves the return type of model.factory.create too early and isn't able to retain a more specific type + // than IChannel here. + const newClient: Client = { + containerRuntime, + dataStoreRuntime, + channel: channel as ReturnType, + }; + options.emitter.emit("clientCreate", newClient); + return newClient; +} + +async function loadClient( + containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection, + summarizerClient: ClientWithStashData, + factory: TChannelFactory, + clientId: string, + options: Omit, + supportStashing: boolean = false, +): Promise> { + const loadData: ClientLoadData = + summarizerClient.stashData === undefined + ? createLoadData(summarizerClient, false) + : createLoadDataFromStashData(summarizerClient, summarizerClient.stashData); + return loadClientFromSummaries( + containerRuntimeFactory, + loadData, + factory, + clientId, + options, + supportStashing, + ); +} + +async function loadClientFromSummaries( + containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection, + loadData: ClientLoadData, + factory: TChannelFactory, + clientId: string, + options: Omit, + supportStashing: boolean = false, +): Promise> { + const { summaries, minimumSequenceNumber } = loadData; + const stashData = supportStashing ? structuredClone(loadData) : undefined; + + const dataStoreRuntime = new MockFluidDataStoreRuntime({ + clientId, + idCompressor: + options.idCompressorFactory === undefined || summaries.idCompressorSummary === undefined + ? undefined + : options.idCompressorFactory(summaries.idCompressorSummary), + }); + const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime, { + minimumSequenceNumber, + trackRemoteOps: supportStashing, + }); + const services: IChannelServices = { + deltaConnection: dataStoreRuntime.createDeltaConnection(), + objectStorage: MockStorage.createFromSummary(summaries.summary), + }; + + const channel = (await factory.load( + dataStoreRuntime, + clientId, + services, + factory.attributes, + )) as ReturnType; + channel.connect(services); + + const newClient: ClientWithStashData = { + channel, + containerRuntime, + dataStoreRuntime, + stashData, + }; + + options.emitter.emit("clientCreate", newClient); + return newClient; +} + +async function loadDetached( + containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection, + summarizerClient: ClientWithStashData, + factory: TChannelFactory, + clientId: string, + options: Omit, +): Promise> { + // as in production, emulate immediate finalizing of IDs when attaching + finalizeAllocatedIds(summarizerClient); + + const { summaries } = + summarizerClient.stashData === undefined + ? createLoadData(summarizerClient, true) + : createLoadDataFromStashData(summarizerClient, summarizerClient.stashData); + + const idCompressor = options.idCompressorFactory?.(summaries.idCompressorSummary); + + const dataStoreRuntime = new MockFluidDataStoreRuntime({ + clientId, + idCompressor, + attachState: AttachState.Detached, + }); + const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime); + const services: IChannelServices = { + deltaConnection: dataStoreRuntime.createDeltaConnection(), + objectStorage: MockStorage.createFromSummary(summaries.summary), + }; + + const channel = (await factory.load( + dataStoreRuntime, + clientId, + services, + factory.attributes, + )) as ReturnType; + + if (summarizerClient.stashData) { + await containerRuntime.initializeWithStashedOps(summarizerClient.containerRuntime); + } + + const newClient: Client = { + channel, + containerRuntime, + dataStoreRuntime, + }; + options.emitter.emit("clientCreate", newClient); + return newClient; +} + +function finalizeAllocatedIds(client: { + dataStoreRuntime: { idCompressor?: IIdCompressorCore }; +}): void { + const compressor = client.dataStoreRuntime.idCompressor; + if (compressor !== undefined) { + const range = compressor.takeNextCreationRange(); + if (range.ids !== undefined) { + compressor.finalizeCreationRange(range); + } + } +} + +/** + * Gets a friendly ID for a client based on its index in the client list. + * This exists purely for easier debugging--reasoning about client "A" is easier than reasoning + * about client "3e8a621a-7b35-414b-897f-8795962fb415". + */ +function makeFriendlyClientId(random: IRandom, index: number): string { + return index < 26 ? String.fromCodePoint(index + 65) : random.uuid4(); +} + +/** + * Runs the provided DDS fuzz model. All functionality is already assumed to be mixed in. + * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to + * expose at the package level if we want to expose some of the harness's building blocks. + */ +export async function runTestForSeed< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, +>( + model: DDSFuzzModel, + options: Omit, + seed: number, + saveInfo?: SaveInfo, +): Promise> { + const random = makeRandom(seed); + const containerRuntimeFactory = new MockContainerRuntimeFactoryForReconnection( + options.containerRuntimeOptions, + ); + + const startDetached = options.detachedStartOptions.numOpsBeforeAttach !== 0; + const initialClient = createDetachedClient( + containerRuntimeFactory, + model.factory, + startDetached ? makeFriendlyClientId(random, 0) : "summarizer", + options, + ); + if (!startDetached) { + finalizeAllocatedIds(initialClient); + initialClient.dataStoreRuntime.setAttachState(AttachState.Attached); + const services: IChannelServices = { + deltaConnection: initialClient.dataStoreRuntime.createDeltaConnection(), + objectStorage: new MockStorage(), + }; + initialClient.channel.connect(services); + } + + const clients = startDetached + ? [initialClient] + : await Promise.all( + Array.from({ length: options.numberOfClients }, async (_, i) => + loadClient( + containerRuntimeFactory, + initialClient, + model.factory, + makeFriendlyClientId(random, i), + options, + options.clientJoinOptions?.stashableClientProbability + ? random.bool(options.clientJoinOptions.stashableClientProbability) + : false, + ), + ), + ); + const summarizerClient = initialClient; + const handles = Array.from({ length: 5 }).map(() => uuid()); + let handleGenerated = false; + const initialState: DDSFuzzTestState = { + clients, + summarizerClient, + containerRuntimeFactory, + random: { + ...random, + handle: () => { + handleGenerated = true; + return new DDSFuzzHandle( + random.pick(handles), + // this is wonky, as get on this handle will always resolve via + // the summarizer client, but since we just return the absolute path + // it doesn't really matter, and remote handles will use + // the right handle context when they are deserialized + // by the dds. + // + // we re-used this hack a few time below, because + // we don't have the real client + initialState.summarizerClient.dataStoreRuntime, + ); + }, + }, + client: makeUnreachableCodePathProxy("client"), + isDetached: startDetached, + }; + + options.emitter.emit("testStart", initialState); + + const serializer = new FluidSerializer(initialState.summarizerClient.dataStoreRuntime); + const bind = new DDSFuzzHandle("", initialState.summarizerClient.dataStoreRuntime); + + let operationCount = 0; + const generator = model.generatorFactory(); + const finalState = await performFuzzActionsAsync( + async (state) => serializer.encode(await generator(state), bind) as TOperation, + async (state, operation) => { + const decodedHandles = serializer.decode(operation) as TOperation; + options.emitter.emit("operation", decodedHandles); + operationCount++; + return model.reducer(state, decodedHandles); + }, + initialState, + saveInfo, + ); + + // Sanity-check that the generator produced at least one operation. If it failed to do so, + // this usually indicates an error on the part of the test author. + assert(operationCount > 0, "Generator should have produced at least one operation."); + + if (options.handleGenerationDisabled !== true) { + assert( + handleGenerated, + "no handles were generated; tests should generate and use handle via random.handle, or disable handles for the test", + ); + } + + options.emitter.emit("testEnd", finalState); + + return finalState; +} + +function runTest( + model: DDSFuzzModel, + options: InternalOptions, + seed: number, + saveInfo: SaveInfo | undefined, +): void { + const itFn = options.only.has(seed) ? it.only : options.skip.has(seed) ? it.skip : it; + itFn(`workload: ${model.workloadName} seed: ${seed}`, async function () { + const inCi = !!process.env.TF_BUILD; + const shouldMinimize = + !options.skipMinimization && saveInfo && saveInfo.saveOnFailure !== false && !inCi; + + // 10 seconds per test should be quite a bit more than is necessary, but + // a timeout during minimization can cause bad UX because it obfuscates + // the actual error + // + // it should be noted that if a timeout occurs during minimization, the + // intermediate results are not lost and will still be written to the file. + const noMinimizationTimeout = this.timeout() === 0 ? 0 : Math.max(2000, this.timeout()); + this.timeout(shouldMinimize ? 5 * noMinimizationTimeout : noMinimizationTimeout); + + try { + // don't write to files in CI + await runTestForSeed(model, options, seed, inCi ? undefined : saveInfo); + } catch (error) { + if (!shouldMinimize) { + throw error; + } + const savePath: string = (saveInfo.saveOnFailure as SaveDestination).path; + let file: Buffer; + try { + file = readFileSync(savePath); + } catch { + // File could not be read and likely does not exist. + // Test may have failed outside of the fuzz test portion (on setup or teardown). + // Throw original error that made test fail. + throw error; + } + const operations = JSON.parse(file.toString()) as TOperation[]; + const minimizer = new FuzzTestMinimizer(model, options, operations, seed, saveInfo, 3); + + const minimized = await minimizer.minimize(); + await saveOpsToFile(savePath, minimized); + + throw error; + } + }); +} + +type InternalOptions = Omit & { + only: Set; + skip: Set; +}; + +function isInternalOptions(options: DDSFuzzSuiteOptions): options is InternalOptions { + return options.only instanceof Set && options.skip instanceof Set; +} + +/** + * Some reducers require preconditions be met which are validated by their generator. + * The validation can be lost if the generator is not run. + * The primary case where this happens is during minimization. If a reducer detects this + * problem, they can throw this error type, and minimization will consider the current + * test invalid, rather than continuing to test invalid scenarios. + * @internal + */ +export class ReducerPreconditionError extends Error {} + +/** + * Performs the test again to verify if the DDS still fails with the same error message. + * + * @internal + */ +export async function replayTest< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, +>( + ddsModel: DDSFuzzModel, + seed: number, + operations: TOperation[], + saveInfo?: SaveInfo, + providedOptions?: Partial, +): Promise { + const options = { + ...defaultDDSFuzzSuiteOptions, + ...providedOptions, + only: new Set(providedOptions?.only ?? []), + skip: new Set(providedOptions?.skip ?? []), + }; + + const _model = getFullModel(ddsModel, options); + + const model = { + ..._model, + // We lose some type safety here because the options interface isn't generic + generatorFactory: (): AsyncGenerator => + asyncGeneratorFromArray(operations), + }; + + await runTestForSeed(model, options, seed, saveInfo); +} + +export function generateTestSeeds(testCount: number, stressMode: StressMode): number[] { + switch (stressMode) { + case StressMode.Short: + case StressMode.Normal: { + // Deterministic, fixed seeds + return Array.from({ length: testCount }, (_, i) => i); + } + + case StressMode.Long: { + // Non-deterministic, random seeds + const random = makeRandom(); + const longModeFactor = 2; + const initialSeed = random.integer( + 0, + Number.MAX_SAFE_INTEGER - longModeFactor * testCount, + ); + return Array.from({ length: testCount * longModeFactor }, (_, i) => initialSeed + i); + } + + default: { + throw new Error(`Unsupported stress mode: ${stressMode}`); + } + } +} + +/** + * Creates a suite of eventual consistency tests for a particular DDS model. + * @internal + */ +export function createDDSFuzzSuite< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, +>( + ddsModel: DDSFuzzModel, + providedOptions?: Partial, +): void { + const options = { + ...defaultDDSFuzzSuiteOptions, + ...providedOptions, + }; + + const only = new Set(options.only); + const skip = new Set(options.skip); + Object.assign(options, { only, skip }); + assert(isInternalOptions(options)); + + const model = getFullModel(ddsModel, options); + + const describeFuzz = createFuzzDescribe({ defaultTestCount: options.defaultTestCount }); + describeFuzz(model.workloadName, ({ testCount, stressMode }) => { + before(() => { + if (options.saveFailures !== false) { + mkdirSync(getSaveDirectory(options.saveFailures.directory, model), { + recursive: true, + }); + } + if (options.saveSuccesses !== false) { + mkdirSync(getSaveDirectory(options.saveSuccesses.directory, model), { + recursive: true, + }); + } + }); + + const seeds = generateTestSeeds(testCount, stressMode); + for (const seed of seeds) { + runTest(model, options, seed, getSaveInfo(model, options, seed)); + } + + if (options.replay !== undefined) { + const seed = options.replay; + describe.only(`replay from file`, () => { + const saveInfo = getSaveInfo(model, options, seed); + assert( + saveInfo.saveOnFailure !== false, + "Cannot replay a file without a directory to save files in!", + ); + const operations = options.parseOperations( + readFileSync(saveInfo.saveOnFailure.path).toString(), + ); + + const replayModel = { + ...model, + // We lose some type safety here because the options interface isn't generic + generatorFactory: (): AsyncGenerator => + asyncGeneratorFromArray(operations as TOperation[]), + }; + runTest(replayModel, options, seed, undefined); + }); + } + }); +} + +const getFullModel = < + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, +>( + ddsModel: DDSFuzzModel, + options: DDSFuzzSuiteOptions, +): DDSFuzzModel< + TChannelFactory, + | TOperation + | AddClient + | Attach + | Attaching + | Rehydrate + | ChangeConnectionState + | TriggerRebase + | Synchronize + | StashClient +> => + mixinAttach( + mixinSynchronization( + mixinNewClient( + mixinStashedClient( + mixinClientSelection( + mixinReconnect(mixinRebase(ddsModel, options), options), + options, + ), + options, + ), + options, + ), + options, + ), + options, + ); + +/** + * {@inheritDoc (createDDSFuzzSuite:function)} + * @internal + */ +// Explicit usage of namespace needed for api-extractor. +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace createDDSFuzzSuite { + /** + * Runs only the provided seeds. + * + * @example + * + * ```typescript + * // Runs only seed 42 for the given model. + * createDDSFuzzSuite.only(42)(model); + * ``` + * @internal + */ + export const only = + (...seeds: number[]) => + ( + ddsModel: DDSFuzzModel, + providedOptions?: Partial, + ): void => + createDDSFuzzSuite(ddsModel, { + ...providedOptions, + only: [...seeds, ...(providedOptions?.only ?? [])], + }); + + /** + * Skips the provided seeds. + * + * @example + * + * ```typescript + * // Skips seed 42 for the given model. + * createDDSFuzzSuite.skip(42)(model); + * ``` + * @internal + */ + export const skip = + (...seeds: number[]) => + ( + ddsModel: DDSFuzzModel, + providedOptions?: Partial, + ): void => + createDDSFuzzSuite(ddsModel, { + ...providedOptions, + skip: [...seeds, ...(providedOptions?.skip ?? [])], + }); +} diff --git a/packages/test/local-server-stress-tests/src/minification.ts b/packages/test/local-server-stress-tests/src/minification.ts new file mode 100644 index 000000000000..965973a84766 --- /dev/null +++ b/packages/test/local-server-stress-tests/src/minification.ts @@ -0,0 +1,250 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { TypedEventEmitter } from "@fluid-internal/client-utils"; +import type { SaveInfo } from "@fluid-private/stochastic-test-utils"; +import { makeRandom } from "@fluid-private/stochastic-test-utils"; +import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; + +import type { + BaseOperation, + DDSFuzzHarnessEvents, + DDSFuzzModel, + DDSFuzzSuiteOptions, +} from "./localServerStressHarness.js"; +import { ReducerPreconditionError, replayTest } from "./localServerStressHarness.js"; + +/** + * A function which takes in an operation and modifies it by reference to be more + * minimal. + * + * This function should be a small step forward and should avoid expensive + * computations, as it will be run potentially thousands of times. + * + * A good example of a minimization transform is: + * + * ```ts + * (op) => { + * // this transform only applies to text insertion ops + * if (op.type !== "addText") { + * return; + * } + * + * // shift the insertion index to the left by one. this makes the index + * // a smaller number and may allow other ops to be shifted to the left + * // as well + * if (op.index > 0) { + * op.index -= 1; + * } + * } + * ``` + * + * @internal + */ +export type MinimizationTransform = (op: TOperation) => void; + +export class FuzzTestMinimizer< + TChannelFactory extends IChannelFactory, + TOperation extends BaseOperation, +> { + private initialError?: { message: string; op: BaseOperation }; + private readonly transforms: MinimizationTransform[]; + private readonly random = makeRandom(); + + constructor( + readonly ddsModel: DDSFuzzModel, + readonly providedOptions: Partial, + readonly operations: TOperation[], + readonly seed: number, + readonly saveInfo: SaveInfo, + readonly numIterations: number = 1000, + ) { + this.transforms = ddsModel.minimizationTransforms ?? []; + } + + async minimize(): Promise { + const firstError = await this.assertFails(); + + if (!firstError) { + throw new Error( + "Attempted to minimize fuzz test, but the original case didn't fail. " + + "This can happen if the original test failed at operation generation time rather than as part of a reducer. " + + "Use the `skipMinimization` option to skip minimization in this case.", + ); + } + + await this.tryDeleteEachOp(); + + if (this.transforms.length === 0) { + return this.operations; + } + + for (let i = 0; i < this.numIterations; i += 1) { + await this.applyTransforms(); + // some minimizations can only occur if two or more ops are modified + // at the same time + for (let j = 0; j < 50; j++) { + await this.applyNRandomTransforms(2); + await this.applyNRandomTransforms(3); + } + } + + await this.tryDeleteEachOp(); + + return this.operations; + } + + private async tryDeleteEachOp(): Promise { + let idx = this.operations.length - 1; + + while (idx > 0) { + const deletedOp = this.operations.splice(idx, 1)[0]; + + // don't remove attach ops, as it creates invalid scenarios + if (deletedOp.type === "attach" || !(await this.assertFails())) { + this.operations.splice(idx, 0, deletedOp); + } + + idx -= 1; + } + } + + /** + * Apply all transforms in a random order + */ + private async applyTransforms(): Promise { + const transforms = [...this.transforms]; + this.random.shuffle(transforms); + + for (const transform of transforms) { + await this.applyTransform(transform); + } + } + + private async applyNRandomTransforms(n: number): Promise { + if (n > this.operations.length) { + return; + } + + // select `n` random transforms. duplicates are allowed. + const transforms = Array.from({ length: n }) + .fill(undefined) + .map(() => this.random.pick(this.transforms)); + + // select `n` random operations without duplicates + let operationIdxs = [...Array.from({ length: this.operations.length }).keys()]; + this.random.shuffle(operationIdxs); + operationIdxs = operationIdxs.slice(0, n); + + if (transforms.length !== operationIdxs.length) { + throw new Error( + `mismatch in number of operations and transforms: ${transforms.length} vs ${operationIdxs.length}`, + ); + } + + const originalOperations: [string, number][] = []; + + for (let i = 0; i < transforms.length; i++) { + const transform = transforms[i]; + const op = this.operations[operationIdxs[i]]; + + originalOperations.push([JSON.stringify(op), operationIdxs[i]]); + + transform(op); + } + + if (!(await this.assertFails())) { + for (const [op, idx] of originalOperations) { + this.operations[idx] = JSON.parse(op) as TOperation; + } + } + } + + /** + * Apply a given transform on each op until it can no longer make progress + */ + private async applyTransform(transform: MinimizationTransform): Promise { + for (let opIdx = this.operations.length - 1; opIdx >= 0; opIdx--) { + // apply this transform at most 10 times on the current op + for (let i = 0; i < 10; i++) { + const op = this.operations[opIdx]; + + // deep clone the op as transforms modify by reference + const originalOp = JSON.stringify(op); + + transform(op); + + if (JSON.stringify(op) === originalOp) { + break; + } + + if (!(await this.assertFails())) { + this.operations[opIdx] = JSON.parse(originalOp) as TOperation; + break; + } + } + } + } + + /** + * Returns whether or not the test still fails with the same error message. + * + * We use the simple heuristic of verifying the error message is the same + * to avoid dealing with transforms that would result in invalid ops + */ + private async assertFails(): Promise { + const emitter = (this.providedOptions.emitter ??= + new TypedEventEmitter()); + + let lastOp: BaseOperation = { type: "___none___" }; + const lastOpTracker = (op: BaseOperation): void => { + lastOp = op; + }; + emitter.on("operationStart", lastOpTracker); + try { + await replayTest( + this.ddsModel, + this.seed, + this.operations, + undefined, + this.providedOptions, + ); + return false; + } catch (error: unknown) { + if ( + !error || + !(error instanceof Error) || + error instanceof ReducerPreconditionError || + error.stack === undefined + ) { + return false; + } + + const stackLines = error.stack.split("\n").map((s) => s.trim()); + + const stackTop = stackLines.findIndex((s) => s.startsWith("at")); + + const message = stackLines[stackTop].startsWith("at assert ") + ? // Reproduce based on the final two lines+col of the error if it is an assert error + // This ensures the same assert is triggered by the minified test + stackLines + .slice(stackTop, stackTop + 2) + .join("\n") + : // Otherwise the final line is sufficient + stackLines[stackTop]; + + if (this.initialError === undefined) { + this.initialError = { message, op: lastOp }; + return true; + } + + return ( + message === this.initialError.message && this.initialError.op.type === lastOp.type + ); + } finally { + emitter.off("operation", lastOpTracker); + } + } +} diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts new file mode 100644 index 000000000000..fa08682e3fe7 --- /dev/null +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -0,0 +1,4 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ diff --git a/packages/test/local-server-stress-tests/src/test/tsconfig.json b/packages/test/local-server-stress-tests/src/test/tsconfig.json new file mode 100644 index 000000000000..3516d9e5ed83 --- /dev/null +++ b/packages/test/local-server-stress-tests/src/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../../common/build/build-common/tsconfig.test.node16.json", + "compilerOptions": { + "rootDir": "../", + "outDir": "../../lib", + "types": ["mocha", "node"], + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + }, +} diff --git a/packages/test/local-server-stress-tests/src/utils.ts b/packages/test/local-server-stress-tests/src/utils.ts new file mode 100644 index 000000000000..68d881db3ca0 --- /dev/null +++ b/packages/test/local-server-stress-tests/src/utils.ts @@ -0,0 +1,107 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { ContainerRuntimeFactoryWithDefaultDataStore } from "@fluidframework/aqueduct/internal"; +import { + type IFluidCodeDetails, + type ILoaderOptions, + type IRuntimeFactory, + ICodeDetailsLoader, +} from "@fluidframework/container-definitions/internal"; +import type { ILoaderProps } from "@fluidframework/container-loader/internal"; +import type { + IDocumentServiceFactory, + IUrlResolver, +} from "@fluidframework/driver-definitions/internal"; +import { + LocalDocumentServiceFactory, + LocalResolver, +} from "@fluidframework/local-driver/internal"; +import { SharedMap } from "@fluidframework/map/internal"; +import type { IFluidDataStoreFactory } from "@fluidframework/runtime-definitions/internal"; +import { ILocalDeltaConnectionServer } from "@fluidframework/server-local-server"; +import { TestFluidObjectFactory, LocalCodeLoader } from "@fluidframework/test-utils/internal"; + +/** + * This allows the input object to be general, + * and the default object to be specific, + * which maintains strong typing for both inputs, and the defaults in the result. + * So if a user specifies a value, that values type will be strongly specified on the Result. + * However if the user does not specify an option input, the result will also get a strong + * type based the default. + */ +export type OptionalToDefault = { + [P in keyof TDefault]: P extends keyof TInput + ? Exclude extends never + ? TDefault[P] + : TInput[P] + : TDefault[P]; +}; + +export interface CreateLoaderParams { + deltaConnectionServer: ILocalDeltaConnectionServer; + codeDetails?: IFluidCodeDetails; + defaultDataStoreFactory?: IFluidDataStoreFactory; + runtimeFactory?: IRuntimeFactory; + codeLoader?: ICodeDetailsLoader; + documentServiceFactory?: IDocumentServiceFactory; + urlResolver?: IUrlResolver; + options?: ILoaderOptions; +} + +export interface CreateLoaderDefaultResults + extends Required> { + documentServiceFactory: LocalDocumentServiceFactory; + urlResolver: LocalResolver; + codeLoader: LocalCodeLoader; + defaultDataStoreFactory: TestFluidObjectFactory; + runtimeFactory: ContainerRuntimeFactoryWithDefaultDataStore; + loaderProps: ILoaderProps; +} + +export function createLoader( + opts: T, +): OptionalToDefault { + const deltaConnectionServer = opts.deltaConnectionServer; + const documentServiceFactory = + opts.documentServiceFactory ?? new LocalDocumentServiceFactory(deltaConnectionServer); + + const urlResolver = opts.urlResolver ?? new LocalResolver(); + + const defaultDataStoreFactory = + opts.defaultDataStoreFactory ?? + new TestFluidObjectFactory([["map", SharedMap.getFactory()]], "default"); + + const runtimeFactory = + opts.runtimeFactory ?? + new ContainerRuntimeFactoryWithDefaultDataStore({ + defaultFactory: defaultDataStoreFactory, + registryEntries: [ + [defaultDataStoreFactory.type, Promise.resolve(defaultDataStoreFactory)], + ], + }); + + const codeDetails = opts.codeDetails ?? { package: "test" }; + + const codeLoader = opts.codeLoader ?? new LocalCodeLoader([[codeDetails, runtimeFactory]]); + + const loaderProps = { + codeLoader, + documentServiceFactory, + urlResolver, + }; + + const rtn: OptionalToDefault = { + deltaConnectionServer, + documentServiceFactory, + urlResolver, + codeDetails, + defaultDataStoreFactory, + runtimeFactory, + codeLoader, + loaderProps, + }; + return rtn as OptionalToDefault; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1ad032e6969..d401419a8eba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13730,6 +13730,138 @@ importers: specifier: ^5.1.4 version: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.97.1) + packages/test/local-server-stress-tests: + devDependencies: + '@biomejs/biome': + specifier: ~1.9.3 + version: 1.9.4 + '@fluid-experimental/tree': + specifier: workspace:~ + version: link:../../../experimental/dds/tree + '@fluid-internal/client-utils': + specifier: workspace:~ + version: link:../../common/client-utils + '@fluid-internal/mocha-test-setup': + specifier: workspace:~ + version: link:../mocha-test-setup + '@fluid-private/stochastic-test-utils': + specifier: workspace:~ + version: link:../stochastic-test-utils + '@fluid-private/test-drivers': + specifier: workspace:~ + version: link:../test-drivers + '@fluidframework/aqueduct': + specifier: workspace:~ + version: link:../../framework/aqueduct + '@fluidframework/build-common': + specifier: ^2.0.3 + version: 2.0.3 + '@fluidframework/build-tools': + specifier: ^0.51.0 + version: 0.51.0(@types/node@18.19.67) + '@fluidframework/container-definitions': + specifier: workspace:~ + version: link:../../common/container-definitions + '@fluidframework/container-loader': + specifier: workspace:~ + version: link:../../loader/container-loader + '@fluidframework/container-runtime': + specifier: workspace:~ + version: link:../../runtime/container-runtime + '@fluidframework/container-runtime-definitions': + specifier: workspace:~ + version: link:../../runtime/container-runtime-definitions + '@fluidframework/core-interfaces': + specifier: workspace:~ + version: link:../../common/core-interfaces + '@fluidframework/core-utils': + specifier: workspace:~ + version: link:../../common/core-utils + '@fluidframework/datastore': + specifier: workspace:~ + version: link:../../runtime/datastore + '@fluidframework/datastore-definitions': + specifier: workspace:~ + version: link:../../runtime/datastore-definitions + '@fluidframework/driver-definitions': + specifier: workspace:~ + version: link:../../common/driver-definitions + '@fluidframework/driver-utils': + specifier: workspace:~ + version: link:../../loader/driver-utils + '@fluidframework/eslint-config-fluid': + specifier: ^5.6.0 + version: 5.6.0(eslint@8.55.0)(typescript@5.4.5) + '@fluidframework/id-compressor': + specifier: workspace:~ + version: link:../../runtime/id-compressor + '@fluidframework/local-driver': + specifier: workspace:~ + version: link:../../drivers/local-driver + '@fluidframework/map': + specifier: workspace:~ + version: link:../../dds/map + '@fluidframework/runtime-definitions': + specifier: workspace:~ + version: link:../../runtime/runtime-definitions + '@fluidframework/runtime-utils': + specifier: workspace:~ + version: link:../../runtime/runtime-utils + '@fluidframework/sequence': + specifier: workspace:~ + version: link:../../dds/sequence + '@fluidframework/server-local-server': + specifier: ^5.0.0 + version: 5.0.0 + '@fluidframework/telemetry-utils': + specifier: workspace:~ + version: link:../../utils/telemetry-utils + '@fluidframework/test-utils': + specifier: workspace:~ + version: link:../test-utils + '@fluidframework/tree': + specifier: workspace:~ + version: link:../../dds/tree + '@types/mocha': + specifier: ^10.0.10 + version: 10.0.10 + '@types/node': + specifier: ^18.19.0 + version: 18.19.67 + '@types/uuid': + specifier: ^9.0.2 + version: 9.0.8 + c8: + specifier: ^8.0.1 + version: 8.0.1 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + eslint: + specifier: ~8.55.0 + version: 8.55.0 + mocha: + specifier: ^10.2.0 + version: 10.8.2 + mocha-multi-reporters: + specifier: ^1.5.1 + version: 1.5.1(mocha@10.8.2) + prettier: + specifier: ~3.0.3 + version: 3.0.3 + rimraf: + specifier: ^4.4.0 + version: 4.4.1 + ts-loader: + specifier: ^9.5.1 + version: 9.5.1(typescript@5.4.5)(webpack@5.97.1) + typescript: + specifier: ~5.4.5 + version: 5.4.5 + uuid: + specifier: ^9.0.0 + version: 9.0.1 + packages/test/local-server-tests: devDependencies: '@biomejs/biome': From 9f4da13bc2a183090e598776c61de157ecf090dc Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 28 Jan 2025 14:02:09 -0800 Subject: [PATCH 02/54] a bunch of compile fixes --- .../src/localServerStressHarness.ts | 785 ++++-------------- 1 file changed, 156 insertions(+), 629 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 4ac3557f0dff..4abe467cdc43 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -22,7 +22,6 @@ import { asyncGeneratorFromArray, chainAsync, createFuzzDescribe, - createWeightedAsyncGenerator, defaultOptions, done, interleaveAsync, @@ -31,37 +30,30 @@ import { saveOpsToFile, takeAsync, } from "@fluid-private/stochastic-test-utils"; -import { AttachState } from "@fluidframework/container-definitions"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; import { unreachableCase } from "@fluidframework/core-utils/internal"; import type { IChannelFactory, - IChannelServices, } from "@fluidframework/datastore-definitions/internal"; -import type { IIdCompressor } from "@fluidframework/id-compressor"; -import type { IIdCompressorCore } from "@fluidframework/id-compressor/internal"; -import { FluidSerializer } from "@fluidframework/shared-object-base/internal"; -import { - MockContainerRuntimeFactoryForReconnection, - MockFluidDataStoreRuntime, - MockStorage, -} from "@fluidframework/test-runtime-utils/internal"; -import type { IMockContainerRuntimeOptions } from "@fluidframework/test-runtime-utils/internal"; -import { v4 as uuid } from "uuid"; - -import { - type Client, - type ClientLoadData, - type ClientWithStashData, - type FuzzSerializedIdCompressor, - createLoadData, - createLoadDataFromStashData, - hasStashData, -} from "./clientLoading.js"; -import { DDSFuzzHandle } from "./ddsFuzzHandle.js"; import type { MinimizationTransform } from "./minification.js"; import { FuzzTestMinimizer } from "./minification.js"; +import { + createLocalResolverCreateNewRequest, + LocalDocumentServiceFactory, + LocalResolver, +} from "@fluidframework/local-driver/internal"; +import { ILocalDeltaConnectionServer, LocalDeltaConnectionServer } from "@fluidframework/server-local-server"; +import type { ICodeDetailsLoader, IContainer, IFluidCodeDetails, IRuntimeFactory } from "@fluidframework/container-definitions/internal"; +import { + ConnectionState, + createDetachedContainer, + loadExistingContainer, +} from "@fluidframework/container-loader/internal"; +import { LocalCodeLoader } from "@fluidframework/test-utils/internal"; +import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; +import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal"; + const isOperationType = ( type: O["type"], op: BaseOperation, @@ -74,25 +66,24 @@ export interface DDSRandom extends IRandom { handle(): IFluidHandle; } + +export interface Client{ + container: IContainer; + id: string; +} + /** * @internal */ export interface DDSFuzzTestState extends BaseFuzzTestState { - containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection; + localDeltaConnectionServer: ILocalDeltaConnectionServer; + codeLoader: ICodeDetailsLoader; + containerUrl?: string; - random: DDSRandom; + random: IRandom; - /** - * Client which is responsible for summarizing. This client remains connected and read-only - * throughout the test. - * - * This client is also used for consistency validation, as eventual consistency bugs are - * typically easier to reason about when one client was readonly. - */ - summarizerClient: Client; clients: Client[]; - // Client which was selected to perform an operation on client: Client; isDetached: boolean; } @@ -111,30 +102,6 @@ export interface BaseOperation { type: number | string; } -/** - * @internal - */ -export interface ChangeConnectionState { - type: "changeConnectionState"; - connected: boolean; -} - -/** - * @internal - */ -export interface StashClient { - type: "stashClient"; - existingClientId: string; - newClientId: string; -} - -/** - * @internal - */ -export interface HandlePicked { - type: "handlePicked"; - handleId: string; -} /** * @internal @@ -143,35 +110,13 @@ export interface Attach { type: "attach"; } -/** - * @internal - */ -export interface Attaching { - type: "attaching"; - beforeRehydrate?: true; -} - -/** - * @internal - */ -export interface Rehydrate { - type: "rehydrate"; -} - -/** - * @internal - */ -export interface TriggerRebase { - type: "rebase"; -} - /** * @internal */ export interface AddClient { type: "addClient"; - addedClientId: string; - canBeStashed: boolean; + id: string, + url: string; } /** @@ -179,7 +124,7 @@ export interface AddClient { */ export interface Synchronize { type: "synchronize"; - clients?: string[]; + clients?: Client[]; } /** @@ -511,15 +456,6 @@ export interface DDSFuzzSuiteOptions { */ saveSuccesses: false | { directory: string }; - /** - * Options to be provided to the underlying container runtimes {@link @fluidframework/test-runtime-utils#IMockContainerRuntimeOptions}. - * By default nothing will be provided, which means that the runtimes will: - * - use FlushMode.Immediate, which means that all ops will be sent as soon as they are produced, - * therefore all batches have a single op. - * - not use grouped batching. - */ - containerRuntimeOptions?: IMockContainerRuntimeOptions; - /** * Whether or not to skip minimization of fuzz failing test cases. This is useful * when one only cares about the counts or types of errors, and not the @@ -532,13 +468,6 @@ export interface DDSFuzzSuiteOptions { * test case. See {@link MinimizationTransform} for additional context. */ skipMinimization?: boolean; - - /** - * An optional IdCompressor that will be passed to the constructed MockDataStoreRuntime instance. - */ - idCompressorFactory?: ( - summary?: FuzzSerializedIdCompressor, - ) => IIdCompressor & IIdCompressorCore; } /** @@ -582,8 +511,9 @@ export function mixinNewClient< const baseGenerator = model.generatorFactory(); return async (state: TState): Promise => { const baseOp = baseGenerator(state); - const { clients, random, isDetached } = state; + const { clients, random, isDetached, containerUrl } = state; if ( + containerUrl !== undefined && options.clientJoinOptions !== undefined && clients.length < options.clientJoinOptions.maxNumberOfClients && !isDetached && @@ -591,11 +521,9 @@ export function mixinNewClient< ) { return { type: "addClient", - addedClientId: makeFriendlyClientId(random, clients.length), - canBeStashed: options.clientJoinOptions?.stashableClientProbability - ? random.bool(options.clientJoinOptions.stashableClientProbability) - : false, - }; + url: containerUrl, + id: makeFriendlyClientId(random, clients.length) + } satisfies AddClient; } return baseOp; }; @@ -606,21 +534,14 @@ export function mixinNewClient< | MinimizationTransform[] | undefined) ?? []; - minimizationTransforms.push((op: TOperation | AddClient): void => { - if (isClientAddOp(op)) { - op.canBeStashed = false; - } - }); const reducer: AsyncReducer = async (state, op) => { if (isClientAddOp(op)) { const newClient = await loadClient( - state.containerRuntimeFactory, - state.summarizerClient, - model.factory, - op.addedClientId, - options, - op.canBeStashed, + state.localDeltaConnectionServer, + state.codeLoader, + op.id, + op.url, ); state.clients.push(newClient); return state; @@ -636,59 +557,6 @@ export function mixinNewClient< }; } -/** - * Mixes in functionality to disconnect and reconnect clients in a DDS fuzz model. - * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to - * expose at the package level if we want to expose some of the harness's building blocks. - */ -export function mixinReconnect< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, - TState extends DDSFuzzTestState, ->( - model: DDSFuzzModel, - options: DDSFuzzSuiteOptions, -): DDSFuzzModel { - const generatorFactory: () => AsyncGenerator = - () => { - const baseGenerator = model.generatorFactory(); - return async (state): Promise => { - const baseOp = baseGenerator(state); - if (!state.isDetached && state.random.bool(options.reconnectProbability)) { - const client = state.clients.find((c) => c.channel.id === state.client.channel.id); - assert(client !== undefined); - return { - type: "changeConnectionState", - connected: !client.containerRuntime.connected, - }; - } - - return baseOp; - }; - }; - - const minimizationTransforms = model.minimizationTransforms as - | MinimizationTransform[] - | undefined; - - const reducer: AsyncReducer = async ( - state, - operation, - ) => { - if (operation.type === "changeConnectionState") { - state.client.containerRuntime.connected = (operation as ChangeConnectionState).connected; - return state; - } else { - return model.reducer(state, operation as TOperation); - } - }; - return { - ...model, - minimizationTransforms, - generatorFactory, - reducer, - }; -} /** * Mixes in functionality to generate an 'attach' op, which @@ -702,51 +570,27 @@ export function mixinAttach< >( model: DDSFuzzModel, options: DDSFuzzSuiteOptions, -): DDSFuzzModel { - const { numOpsBeforeAttach, rehydrateDisabled, attachingBeforeRehydrateDisable } = +): DDSFuzzModel { + const { numOpsBeforeAttach} = options.detachedStartOptions; if (numOpsBeforeAttach === 0) { // not wrapping the reducer/generator in this case makes stepping through the harness slightly less painful. return model as DDSFuzzModel< TChannelFactory, - TOperation | Attach | Attaching | Rehydrate, + TOperation | Attach , TState >; } - const attachOp = async (): Promise => { + const attachOp = async (): Promise => { return { type: "attach" }; }; - const rehydrateOp = async (): Promise => { - return { type: "rehydrate" }; - }; + const generatorFactory: () => AsyncGenerator< - TOperation | Attach | Attaching | Rehydrate, + TOperation | Attach , TState > = () => { const baseGenerator = model.generatorFactory(); - const rehydrates = rehydrateDisabled - ? [] - : [ - // sometimes mix a single attaching op - // in before rehydrate so we test - // applying stashed ops while detached - createWeightedAsyncGenerator([ - [takeAsync(numOpsBeforeAttach, baseGenerator), numOpsBeforeAttach], - [ - takeAsync( - 1, - async (): Promise => ({ - type: "attaching", - beforeRehydrate: true, - }), - ), - attachingBeforeRehydrateDisable === true ? 0 : 1, - ], - ]), - takeAsync(1, rehydrateOp), - ]; return chainAsync( - ...rehydrates, takeAsync(numOpsBeforeAttach, baseGenerator), takeAsync(1, attachOp), baseGenerator, @@ -754,40 +598,32 @@ export function mixinAttach< }; const minimizationTransforms = model.minimizationTransforms as - | MinimizationTransform[] + | MinimizationTransform[] | undefined; - const reducer: AsyncReducer = async ( + const reducer: AsyncReducer = async ( state, operation, ) => { if (isOperationType("attach", operation)) { state.isDetached = false; assert.equal(state.clients.length, 1); - const clientA: ClientWithStashData = state.clients[0]; - finalizeAllocatedIds(clientA); - clientA.dataStoreRuntime.setAttachState(AttachState.Attached); - const services: IChannelServices = { - deltaConnection: clientA.dataStoreRuntime.createDeltaConnection(), - objectStorage: new MockStorage(), - }; - clientA.channel.connect(services); + const clientA: Client = state.clients[0]; + + await clientA.container.attach(createLocalResolverCreateNewRequest("stress test")); + const url = await clientA.container.getAbsoluteUrl(""); + assert(url !== undefined, "container must have a url"); const clients: Client[] = await Promise.all( Array.from({ length: options.numberOfClients }, async (_, index) => loadClient( - state.containerRuntimeFactory, - clientA, - model.factory, - index === 0 ? "summarizer" : makeFriendlyClientId(state.random, index), - options, - index !== 0 && options.clientJoinOptions?.stashableClientProbability - ? state.random.bool(options.clientJoinOptions.stashableClientProbability) - : false, + state.localDeltaConnectionServer, + state.codeLoader, + url, + makeFriendlyClientId(state.random,index) ), ), ); - // eslint-disable-next-line require-atomic-updates - clientA.stashData = undefined; + // While detached, the initial state was set up so that the 'summarizer client' was the same as the detached client. // This is actually a pretty reasonable representation of what really happens. @@ -803,44 +639,6 @@ export function mixinAttach< clients, summarizerClient, }; - } else if (isOperationType("rehydrate", operation)) { - const clientA = state.clients[0]; - assert.equal(state.clients.length, 1); - - state.containerRuntimeFactory.removeContainerRuntime(clientA.containerRuntime); - - const summarizerClient = await loadDetached( - state.containerRuntimeFactory, - clientA, - model.factory, - makeFriendlyClientId(state.random, 0), - options, - ); - - await model.validateConsistency(clientA, summarizerClient); - - return { - ...state, - isDetached: true, - clients: [summarizerClient], - summarizerClient, - }; - } else if (isOperationType("attaching", operation)) { - assert.equal(state.clients.length, 1); - const clientA: ClientWithStashData = state.clients[0]; - finalizeAllocatedIds(clientA); - - if (operation.beforeRehydrate === true) { - clientA.stashData = createLoadData(clientA, true); - } - clientA.dataStoreRuntime.setAttachState(AttachState.Attaching); - const services: IChannelServices = { - deltaConnection: clientA.dataStoreRuntime.createDeltaConnection(), - objectStorage: new MockStorage(), - }; - clientA.channel.connect(services); - - return state; } return model.reducer(state, operation); }; @@ -852,64 +650,6 @@ export function mixinAttach< }; } -/** - * Mixes in functionality to rebase in-flight batches in a DDS fuzz model. A batch is rebased by - * resending it to the datastores before being sent over the wire. - * - * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to - * expose at the package level if we want to expose some of the harness's building blocks. - */ -export function mixinRebase< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, - TState extends DDSFuzzTestState, ->( - model: DDSFuzzModel, - options: DDSFuzzSuiteOptions, -): DDSFuzzModel { - const generatorFactory: () => AsyncGenerator = () => { - const baseGenerator = model.generatorFactory(); - return async (state): Promise => { - const baseOp = baseGenerator(state); - if (state.random.bool(options.rebaseProbability)) { - const client = state.clients.find((c) => c.channel.id === state.client.channel.id); - assert(client !== undefined); - return { - type: "rebase", - }; - } - - return baseOp; - }; - }; - - const minimizationTransforms = model.minimizationTransforms as - | MinimizationTransform[] - | undefined; - - const reducer: AsyncReducer = async ( - state, - operation, - ) => { - if (isOperationType("rebase", operation)) { - assert( - state.client.containerRuntime.rebase !== undefined, - "Unsupported mock runtime version", - ); - state.client.containerRuntime.rebase(); - return state; - } else { - return model.reducer(state, operation); - } - }; - return { - ...model, - minimizationTransforms, - generatorFactory, - reducer, - }; -} - /** * Mixes in functionality to generate ops which synchronize all clients and assert the resulting state is consistent. * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to @@ -968,9 +708,8 @@ export function mixinSynchronization< if (!state.isDetached && state.random.bool(validationStrategy.probability)) { const selectedClients = new Set( state.clients - .filter((client) => client.containerRuntime.connected) + .filter((client) => client.container.connectionState === ConnectionState.Connected) .filter(() => state.random.bool(validationStrategy.clientProbability)) - .map((client) => client.channel.id), ); return { type: "synchronize", clients: [...selectedClients] }; @@ -995,26 +734,20 @@ export function mixinSynchronization< // TODO: Only synchronize listed clients if specified if (isSynchronizeOp(operation)) { const connectedClients = state.clients.filter( - (client) => client.containerRuntime.connected, + (client) => client.container.connectionState === ConnectionState.Connected ); - for (const client of connectedClients) { - assert( - client.containerRuntime.flush !== undefined, - "Unsupported mock runtime version", - ); - client.containerRuntime.flush(); - } - state.containerRuntimeFactory.processAllMessages(); + await Promise.all(connectedClients.map((c)=>new Promise((resolve)=>c.container.isDirty ? c.container.once("saved",()=>resolve()): resolve()))) + if (connectedClients.length > 0) { - const readonlyChannel = state.summarizerClient; + const readonlyChannel = state.clients[0]; for (const client of connectedClients) { try { await model.validateConsistency(readonlyChannel, client); } catch (error: unknown) { if (error instanceof Error) { - error.message = `Comparing client ${readonlyChannel.channel.id} vs client ${client.channel.id}\n${error.message}`; + error.message = `Comparing client ${readonlyChannel.container.clientId} vs client ${client.container.clientId}\n${error.message}`; } throw error; } @@ -1069,14 +802,14 @@ export function mixinClientSelection< ? done : { ...baseOp, - clientId: client.channel.id, + clientId: client.id, }; }; }; const reducer: AsyncReducer = async (state, operation) => { assert(isClientSpec(operation), "operation should have been given a client"); - const client = state.clients.find((c) => c.channel.id === operation.clientId); + const client = state.clients.find((c) => c.id === operation.clientId); assert(client !== undefined); await runInStateWithClient(state, client, async () => model.reducer(state, operation as TOperation), @@ -1089,82 +822,6 @@ export function mixinClientSelection< }; } -export function mixinStashedClient< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, - TState extends DDSFuzzTestState, ->( - model: DDSFuzzModel, - options: DDSFuzzSuiteOptions, -): DDSFuzzModel { - if (options.clientJoinOptions?.stashableClientProbability === undefined) { - return model as DDSFuzzModel; - } - - const generatorFactory: () => AsyncGenerator = () => { - const baseGenerator = model.generatorFactory(); - return async (state): Promise => { - const stashable = state.clients.filter( - (c) => hasStashData(c) && c.containerRuntime.isDirty, - ); - - if (!state.isDetached && stashable.length > 0 && state.random.bool(0.5)) { - const existingClientId = state.random.pick(stashable).channel.id; - const instanceIndex = existingClientId.lastIndexOf("_"); - const instance = - instanceIndex < 0 - ? 0 - : Number.parseInt(existingClientId.slice(instanceIndex + 1), 10); - return { - type: "stashClient", - existingClientId, - newClientId: `${existingClientId}_${instance + 1}`, - }; - } - return baseGenerator(state); - }; - }; - - const reducer: AsyncReducer = async (state, operation) => { - const { clients, containerRuntimeFactory } = state; - if (isOperationType("stashClient", operation)) { - const client = clients.find((c) => c.channel.id === operation.existingClientId); - if (!hasStashData(client)) { - throw new ReducerPreconditionError("client not stashable"); - } - const loadData = createLoadDataFromStashData(client, client.stashData); - - // load a new client from the same state as the original client - const newClient = await loadClientFromSummaries( - containerRuntimeFactory, - loadData, - model.factory, - operation.newClientId, - options, - ); - - await newClient.containerRuntime.initializeWithStashedOps(client.containerRuntime); - - // replace the old client with the new client - return { - ...state, - clients: [...clients.filter((c) => c.channel.id !== client.channel.id), newClient], - }; - } - - return model.reducer(state, operation); - }; - - return { - ...model, - generatorFactory, - reducer, - minimizationTransforms: model.minimizationTransforms as MinimizationTransform< - TOperation | StashClient - >[], - }; -} - /** * This modifies the value of "client" while callback is running, then restores it. * This is does instead of copying the state since the state object is mutable, and running callback might make changes to state (like add new members) which are lost if state is just copied. @@ -1198,167 +855,44 @@ function makeUnreachableCodePathProxy(name: string): T { }); } -function createDetachedClient( - containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection, - factory: TChannelFactory, - clientId: string, - options: Omit, -): Client { - const dataStoreRuntime = new MockFluidDataStoreRuntime({ - clientId, - idCompressor: - options.idCompressorFactory === undefined ? undefined : options.idCompressorFactory(), - attachState: AttachState.Detached, - }); - // Note: we re-use the clientId for the channel id here despite connecting all clients to the same channel: - // this isn't how it would work in a real scenario, but the mocks don't use the channel id for any message - // routing behavior and making all of the object ids consistent helps with debugging and writing more informative - // consistency validation. - const channel: ReturnType = factory.create( - dataStoreRuntime, - clientId, - ); +async function createDetachedClient( + localDeltaConnectionServer: ILocalDeltaConnectionServer, + codeLoader: ICodeDetailsLoader, + codeDetails: IFluidCodeDetails, + id: string, +): Promise> { + + const container = await createDetachedContainer({ + codeLoader, + documentServiceFactory: new LocalDocumentServiceFactory(localDeltaConnectionServer), + urlResolver: new LocalResolver(), + codeDetails + }) - const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime, { - // only track remote ops(which enables initialize from stashed ops), if rehydrate is enabled - trackRemoteOps: options.detachedStartOptions.rehydrateDisabled !== true, - }); - // TS resolves the return type of model.factory.create too early and isn't able to retain a more specific type - // than IChannel here. const newClient: Client = { - containerRuntime, - dataStoreRuntime, - channel: channel as ReturnType, + container, + id, }; - options.emitter.emit("clientCreate", newClient); return newClient; } async function loadClient( - containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection, - summarizerClient: ClientWithStashData, - factory: TChannelFactory, - clientId: string, - options: Omit, - supportStashing: boolean = false, -): Promise> { - const loadData: ClientLoadData = - summarizerClient.stashData === undefined - ? createLoadData(summarizerClient, false) - : createLoadDataFromStashData(summarizerClient, summarizerClient.stashData); - return loadClientFromSummaries( - containerRuntimeFactory, - loadData, - factory, - clientId, - options, - supportStashing, - ); -} - -async function loadClientFromSummaries( - containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection, - loadData: ClientLoadData, - factory: TChannelFactory, - clientId: string, - options: Omit, - supportStashing: boolean = false, -): Promise> { - const { summaries, minimumSequenceNumber } = loadData; - const stashData = supportStashing ? structuredClone(loadData) : undefined; - - const dataStoreRuntime = new MockFluidDataStoreRuntime({ - clientId, - idCompressor: - options.idCompressorFactory === undefined || summaries.idCompressorSummary === undefined - ? undefined - : options.idCompressorFactory(summaries.idCompressorSummary), - }); - const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime, { - minimumSequenceNumber, - trackRemoteOps: supportStashing, - }); - const services: IChannelServices = { - deltaConnection: dataStoreRuntime.createDeltaConnection(), - objectStorage: MockStorage.createFromSummary(summaries.summary), - }; - - const channel = (await factory.load( - dataStoreRuntime, - clientId, - services, - factory.attributes, - )) as ReturnType; - channel.connect(services); - - const newClient: ClientWithStashData = { - channel, - containerRuntime, - dataStoreRuntime, - stashData, - }; - - options.emitter.emit("clientCreate", newClient); - return newClient; -} - -async function loadDetached( - containerRuntimeFactory: MockContainerRuntimeFactoryForReconnection, - summarizerClient: ClientWithStashData, - factory: TChannelFactory, - clientId: string, - options: Omit, + localDeltaConnectionServer: ILocalDeltaConnectionServer, + codeLoader: ICodeDetailsLoader, + id: string, + url: string, ): Promise> { - // as in production, emulate immediate finalizing of IDs when attaching - finalizeAllocatedIds(summarizerClient); - - const { summaries } = - summarizerClient.stashData === undefined - ? createLoadData(summarizerClient, true) - : createLoadDataFromStashData(summarizerClient, summarizerClient.stashData); - - const idCompressor = options.idCompressorFactory?.(summaries.idCompressorSummary); - const dataStoreRuntime = new MockFluidDataStoreRuntime({ - clientId, - idCompressor, - attachState: AttachState.Detached, + const container = await loadExistingContainer({ + documentServiceFactory: new LocalDocumentServiceFactory(localDeltaConnectionServer), + request: {url}, + urlResolver: new LocalResolver(), + codeLoader }); - const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime); - const services: IChannelServices = { - deltaConnection: dataStoreRuntime.createDeltaConnection(), - objectStorage: MockStorage.createFromSummary(summaries.summary), - }; - - const channel = (await factory.load( - dataStoreRuntime, - clientId, - services, - factory.attributes, - )) as ReturnType; - - if (summarizerClient.stashData) { - await containerRuntime.initializeWithStashedOps(summarizerClient.containerRuntime); - } - const newClient: Client = { - channel, - containerRuntime, - dataStoreRuntime, - }; - options.emitter.emit("clientCreate", newClient); - return newClient; -} - -function finalizeAllocatedIds(client: { - dataStoreRuntime: { idCompressor?: IIdCompressorCore }; -}): void { - const compressor = client.dataStoreRuntime.idCompressor; - if (compressor !== undefined) { - const range = compressor.takeNextCreationRange(); - if (range.ids !== undefined) { - compressor.finalizeCreationRange(range); - } + return { + container, + id, } } @@ -1371,6 +905,50 @@ function makeFriendlyClientId(random: IRandom, index: number): string { return index < 26 ? String.fromCodePoint(index + 65) : random.uuid4(); } + +class StressDataObject extends DataObject { + get StressDataObject() { + return this; + } +} + +const stressDataObjectFactory = new DataObjectFactory( + "ParentDataObject", + StressDataObject, + undefined, + {}, +); + + +const runtimeFactory: IRuntimeFactory = { + get IRuntimeFactory() { + return this; + }, + instantiateRuntime: async (context, existing) => { + return loadContainerRuntime({ + context, + existing, + registryEntries: [ + [ + stressDataObjectFactory.type, + Promise.resolve(stressDataObjectFactory), + ], + ], + provideEntryPoint: async (rt) => { + const maybeRoot = await rt.getAliasedDataStoreEntryPoint("default"); + if (maybeRoot === undefined) { + const ds = await rt.createDataStore(stressDataObjectFactory.type); + await ds.trySetAlias("default"); + } + const root = await rt.getAliasedDataStoreEntryPoint("default"); + assert(root !== undefined, "default must exist"); + return root.get(); + }, + }); + }, +}; + + /** * Runs the provided DDS fuzz model. All functionality is already assumed to be mixed in. * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to @@ -1386,87 +964,53 @@ export async function runTestForSeed< saveInfo?: SaveInfo, ): Promise> { const random = makeRandom(seed); - const containerRuntimeFactory = new MockContainerRuntimeFactoryForReconnection( - options.containerRuntimeOptions, - ); const startDetached = options.detachedStartOptions.numOpsBeforeAttach !== 0; - const initialClient = createDetachedClient( - containerRuntimeFactory, - model.factory, + const localDeltaConnectionServer= LocalDeltaConnectionServer.create(); + const codeDetails: IFluidCodeDetails = { + package:"local-server-stress-tests" + }; + const codeLoader = new LocalCodeLoader([[codeDetails,runtimeFactory]]); + const initialClient = await createDetachedClient( + localDeltaConnectionServer, + codeLoader, + codeDetails, startDetached ? makeFriendlyClientId(random, 0) : "summarizer", - options, ); if (!startDetached) { - finalizeAllocatedIds(initialClient); - initialClient.dataStoreRuntime.setAttachState(AttachState.Attached); - const services: IChannelServices = { - deltaConnection: initialClient.dataStoreRuntime.createDeltaConnection(), - objectStorage: new MockStorage(), - }; - initialClient.channel.connect(services); + await initialClient.container.attach(createLocalResolverCreateNewRequest("stress")) } + const url="aas"; const clients = startDetached ? [initialClient] : await Promise.all( Array.from({ length: options.numberOfClients }, async (_, i) => loadClient( - containerRuntimeFactory, - initialClient, - model.factory, + localDeltaConnectionServer, + codeLoader, makeFriendlyClientId(random, i), - options, - options.clientJoinOptions?.stashableClientProbability - ? random.bool(options.clientJoinOptions.stashableClientProbability) - : false, + url, ), ), ); - const summarizerClient = initialClient; - const handles = Array.from({ length: 5 }).map(() => uuid()); - let handleGenerated = false; const initialState: DDSFuzzTestState = { clients, - summarizerClient, - containerRuntimeFactory, - random: { - ...random, - handle: () => { - handleGenerated = true; - return new DDSFuzzHandle( - random.pick(handles), - // this is wonky, as get on this handle will always resolve via - // the summarizer client, but since we just return the absolute path - // it doesn't really matter, and remote handles will use - // the right handle context when they are deserialized - // by the dds. - // - // we re-used this hack a few time below, because - // we don't have the real client - initialState.summarizerClient.dataStoreRuntime, - ); - }, - }, + localDeltaConnectionServer, + codeLoader, + random, client: makeUnreachableCodePathProxy("client"), isDetached: startDetached, }; options.emitter.emit("testStart", initialState); - const serializer = new FluidSerializer(initialState.summarizerClient.dataStoreRuntime); - const bind = new DDSFuzzHandle("", initialState.summarizerClient.dataStoreRuntime); let operationCount = 0; const generator = model.generatorFactory(); const finalState = await performFuzzActionsAsync( - async (state) => serializer.encode(await generator(state), bind) as TOperation, - async (state, operation) => { - const decodedHandles = serializer.decode(operation) as TOperation; - options.emitter.emit("operation", decodedHandles); - operationCount++; - return model.reducer(state, decodedHandles); - }, + generator, + model.reducer, initialState, saveInfo, ); @@ -1475,12 +1019,6 @@ export async function runTestForSeed< // this usually indicates an error on the part of the test author. assert(operationCount > 0, "Generator should have produced at least one operation."); - if (options.handleGenerationDisabled !== true) { - assert( - handleGenerated, - "no handles were generated; tests should generate and use handle via random.handle, or disable handles for the test", - ); - } options.emitter.emit("testEnd", finalState); @@ -1692,29 +1230,18 @@ const getFullModel = < | TOperation | AddClient | Attach - | Attaching - | Rehydrate - | ChangeConnectionState - | TriggerRebase | Synchronize - | StashClient > => mixinAttach( mixinSynchronization( mixinNewClient( - mixinStashedClient( - mixinClientSelection( - mixinReconnect(mixinRebase(ddsModel, options), options), - options, - ), + mixinClientSelection(ddsModel, options), options, ), options, ), options, - ), - options, - ); + ); /** * {@inheritDoc (createDDSFuzzSuite:function)} From 81d1c224f01bc7cc4fae421c59ad55e350f47944 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 28 Jan 2025 14:26:21 -0800 Subject: [PATCH 03/54] format --- .../src/localServerStressHarness.ts | 121 ++++++++---------- 1 file changed, 50 insertions(+), 71 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 4abe467cdc43..5ecc2afb9982 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -32,9 +32,7 @@ import { } from "@fluid-private/stochastic-test-utils"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; import { unreachableCase } from "@fluidframework/core-utils/internal"; -import type { - IChannelFactory, -} from "@fluidframework/datastore-definitions/internal"; +import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; import type { MinimizationTransform } from "./minification.js"; import { FuzzTestMinimizer } from "./minification.js"; @@ -43,8 +41,16 @@ import { LocalDocumentServiceFactory, LocalResolver, } from "@fluidframework/local-driver/internal"; -import { ILocalDeltaConnectionServer, LocalDeltaConnectionServer } from "@fluidframework/server-local-server"; -import type { ICodeDetailsLoader, IContainer, IFluidCodeDetails, IRuntimeFactory } from "@fluidframework/container-definitions/internal"; +import { + ILocalDeltaConnectionServer, + LocalDeltaConnectionServer, +} from "@fluidframework/server-local-server"; +import type { + ICodeDetailsLoader, + IContainer, + IFluidCodeDetails, + IRuntimeFactory, +} from "@fluidframework/container-definitions/internal"; import { ConnectionState, createDetachedContainer, @@ -66,8 +72,7 @@ export interface DDSRandom extends IRandom { handle(): IFluidHandle; } - -export interface Client{ +export interface Client { container: IContainer; id: string; } @@ -102,7 +107,6 @@ export interface BaseOperation { type: number | string; } - /** * @internal */ @@ -115,7 +119,7 @@ export interface Attach { */ export interface AddClient { type: "addClient"; - id: string, + id: string; url: string; } @@ -522,7 +526,7 @@ export function mixinNewClient< return { type: "addClient", url: containerUrl, - id: makeFriendlyClientId(random, clients.length) + id: makeFriendlyClientId(random, clients.length), } satisfies AddClient; } return baseOp; @@ -534,7 +538,6 @@ export function mixinNewClient< | MinimizationTransform[] | undefined) ?? []; - const reducer: AsyncReducer = async (state, op) => { if (isClientAddOp(op)) { const newClient = await loadClient( @@ -557,7 +560,6 @@ export function mixinNewClient< }; } - /** * Mixes in functionality to generate an 'attach' op, which * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to @@ -570,25 +572,17 @@ export function mixinAttach< >( model: DDSFuzzModel, options: DDSFuzzSuiteOptions, -): DDSFuzzModel { - const { numOpsBeforeAttach} = - options.detachedStartOptions; +): DDSFuzzModel { + const { numOpsBeforeAttach } = options.detachedStartOptions; if (numOpsBeforeAttach === 0) { // not wrapping the reducer/generator in this case makes stepping through the harness slightly less painful. - return model as DDSFuzzModel< - TChannelFactory, - TOperation | Attach , - TState - >; + return model as DDSFuzzModel; } - const attachOp = async (): Promise => { + const attachOp = async (): Promise => { return { type: "attach" }; }; - const generatorFactory: () => AsyncGenerator< - TOperation | Attach , - TState - > = () => { + const generatorFactory: () => AsyncGenerator = () => { const baseGenerator = model.generatorFactory(); return chainAsync( takeAsync(numOpsBeforeAttach, baseGenerator), @@ -601,10 +595,7 @@ export function mixinAttach< | MinimizationTransform[] | undefined; - const reducer: AsyncReducer = async ( - state, - operation, - ) => { + const reducer: AsyncReducer = async (state, operation) => { if (isOperationType("attach", operation)) { state.isDetached = false; assert.equal(state.clients.length, 1); @@ -619,12 +610,11 @@ export function mixinAttach< state.localDeltaConnectionServer, state.codeLoader, url, - makeFriendlyClientId(state.random,index) + makeFriendlyClientId(state.random, index), ), ), ); - // While detached, the initial state was set up so that the 'summarizer client' was the same as the detached client. // This is actually a pretty reasonable representation of what really happens. // However, now that we're transitioning to an attached state, the summarizer client should never have any edits. @@ -708,8 +698,10 @@ export function mixinSynchronization< if (!state.isDetached && state.random.bool(validationStrategy.probability)) { const selectedClients = new Set( state.clients - .filter((client) => client.container.connectionState === ConnectionState.Connected) - .filter(() => state.random.bool(validationStrategy.clientProbability)) + .filter( + (client) => client.container.connectionState === ConnectionState.Connected, + ) + .filter(() => state.random.bool(validationStrategy.clientProbability)), ); return { type: "synchronize", clients: [...selectedClients] }; @@ -734,11 +726,17 @@ export function mixinSynchronization< // TODO: Only synchronize listed clients if specified if (isSynchronizeOp(operation)) { const connectedClients = state.clients.filter( - (client) => client.container.connectionState === ConnectionState.Connected + (client) => client.container.connectionState === ConnectionState.Connected, ); - - await Promise.all(connectedClients.map((c)=>new Promise((resolve)=>c.container.isDirty ? c.container.once("saved",()=>resolve()): resolve()))) + await Promise.all( + connectedClients.map( + (c) => + new Promise((resolve) => + c.container.isDirty ? c.container.once("saved", () => resolve()) : resolve(), + ), + ), + ); if (connectedClients.length > 0) { const readonlyChannel = state.clients[0]; @@ -861,13 +859,12 @@ async function createDetachedClient( codeDetails: IFluidCodeDetails, id: string, ): Promise> { - const container = await createDetachedContainer({ codeLoader, documentServiceFactory: new LocalDocumentServiceFactory(localDeltaConnectionServer), urlResolver: new LocalResolver(), - codeDetails - }) + codeDetails, + }); const newClient: Client = { container, @@ -882,18 +879,17 @@ async function loadClient( id: string, url: string, ): Promise> { - const container = await loadExistingContainer({ documentServiceFactory: new LocalDocumentServiceFactory(localDeltaConnectionServer), - request: {url}, + request: { url }, urlResolver: new LocalResolver(), - codeLoader + codeLoader, }); return { container, id, - } + }; } /** @@ -905,7 +901,6 @@ function makeFriendlyClientId(random: IRandom, index: number): string { return index < 26 ? String.fromCodePoint(index + 65) : random.uuid4(); } - class StressDataObject extends DataObject { get StressDataObject() { return this; @@ -919,7 +914,6 @@ const stressDataObjectFactory = new DataObjectFactory( {}, ); - const runtimeFactory: IRuntimeFactory = { get IRuntimeFactory() { return this; @@ -929,10 +923,7 @@ const runtimeFactory: IRuntimeFactory = { context, existing, registryEntries: [ - [ - stressDataObjectFactory.type, - Promise.resolve(stressDataObjectFactory), - ], + [stressDataObjectFactory.type, Promise.resolve(stressDataObjectFactory)], ], provideEntryPoint: async (rt) => { const maybeRoot = await rt.getAliasedDataStoreEntryPoint("default"); @@ -948,7 +939,6 @@ const runtimeFactory: IRuntimeFactory = { }, }; - /** * Runs the provided DDS fuzz model. All functionality is already assumed to be mixed in. * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to @@ -966,11 +956,11 @@ export async function runTestForSeed< const random = makeRandom(seed); const startDetached = options.detachedStartOptions.numOpsBeforeAttach !== 0; - const localDeltaConnectionServer= LocalDeltaConnectionServer.create(); + const localDeltaConnectionServer = LocalDeltaConnectionServer.create(); const codeDetails: IFluidCodeDetails = { - package:"local-server-stress-tests" + package: "local-server-stress-tests", }; - const codeLoader = new LocalCodeLoader([[codeDetails,runtimeFactory]]); + const codeLoader = new LocalCodeLoader([[codeDetails, runtimeFactory]]); const initialClient = await createDetachedClient( localDeltaConnectionServer, codeLoader, @@ -978,9 +968,9 @@ export async function runTestForSeed< startDetached ? makeFriendlyClientId(random, 0) : "summarizer", ); if (!startDetached) { - await initialClient.container.attach(createLocalResolverCreateNewRequest("stress")) + await initialClient.container.attach(createLocalResolverCreateNewRequest("stress")); } - const url="aas"; + const url = "aas"; const clients = startDetached ? [initialClient] @@ -1005,7 +995,6 @@ export async function runTestForSeed< options.emitter.emit("testStart", initialState); - let operationCount = 0; const generator = model.generatorFactory(); const finalState = await performFuzzActionsAsync( @@ -1019,7 +1008,6 @@ export async function runTestForSeed< // this usually indicates an error on the part of the test author. assert(operationCount > 0, "Generator should have produced at least one operation."); - options.emitter.emit("testEnd", finalState); return finalState; @@ -1225,23 +1213,14 @@ const getFullModel = < >( ddsModel: DDSFuzzModel, options: DDSFuzzSuiteOptions, -): DDSFuzzModel< - TChannelFactory, - | TOperation - | AddClient - | Attach - | Synchronize -> => +): DDSFuzzModel => mixinAttach( mixinSynchronization( - mixinNewClient( - mixinClientSelection(ddsModel, options), - options, - ), - options, - ), + mixinNewClient(mixinClientSelection(ddsModel, options), options), options, - ); + ), + options, + ); /** * {@inheritDoc (createDDSFuzzSuite:function)} From ebe34bd8f35d75edba7589674e2331f1cc7c1de4 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 28 Jan 2025 14:28:24 -0800 Subject: [PATCH 04/54] format --- .../src/localServerStressHarness.ts | 126 +++++++----------- .../src/minification.ts | 7 +- 2 files changed, 50 insertions(+), 83 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 5ecc2afb9982..fe1e7ba313d5 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -32,7 +32,6 @@ import { } from "@fluid-private/stochastic-test-utils"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; import { unreachableCase } from "@fluidframework/core-utils/internal"; -import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; import type { MinimizationTransform } from "./minification.js"; import { FuzzTestMinimizer } from "./minification.js"; @@ -72,7 +71,7 @@ export interface DDSRandom extends IRandom { handle(): IFluidHandle; } -export interface Client { +export interface Client { container: IContainer; id: string; } @@ -80,16 +79,15 @@ export interface Client { /** * @internal */ -export interface DDSFuzzTestState - extends BaseFuzzTestState { +export interface DDSFuzzTestState extends BaseFuzzTestState { localDeltaConnectionServer: ILocalDeltaConnectionServer; codeLoader: ICodeDetailsLoader; containerUrl?: string; random: IRandom; - clients: Client[]; - client: Client; + clients: Client[]; + client: Client; isDetached: boolean; } @@ -202,9 +200,8 @@ function getSaveInfo( * @internal */ export interface DDSFuzzModel< - TChannelFactory extends IChannelFactory, TOperation extends BaseOperation, - TState extends DDSFuzzTestState = DDSFuzzTestState, + TState extends DDSFuzzTestState = DDSFuzzTestState, > { /** * Name for this model. This is used for test case naming, and should generally reflect properties @@ -216,11 +213,6 @@ export interface DDSFuzzModel< */ workloadName: string; - /** - * ChannelFactory to instantiate the DDS. - */ - factory: TChannelFactory; - /** * Factory which creates a generator for this model. * @remarks DDS model generators can decide to use the "channel" or "client" field to decide which @@ -239,10 +231,7 @@ export interface DDSFuzzModel< * necessarily have the same set of ops applied). * @throws - An informative error if the channels don't have equivalent data. */ - validateConsistency: ( - channelA: Client, - channelB: Client, - ) => void | Promise; + validateConsistency: (channelA: Client, channelB: Client) => void | Promise; /** * An array of transforms used during fuzz test minimization to reduce test @@ -261,17 +250,17 @@ export interface DDSFuzzHarnessEvents { /** * Raised for each non-summarizer client created during fuzz test execution. */ - (event: "clientCreate", listener: (client: Client) => void); + (event: "clientCreate", listener: (client: Client) => void); /** * Raised after creating the initialState but prior to performing the fuzzActions.. */ - (event: "testStart", listener: (initialState: DDSFuzzTestState) => void); + (event: "testStart", listener: (initialState: DDSFuzzTestState) => void); /** * Raised after all fuzzActions have been completed. */ - (event: "testEnd", listener: (finalState: DDSFuzzTestState) => void); + (event: "testEnd", listener: (finalState: DDSFuzzTestState) => void); /** * Raised before each generated operation is run by its reducer. @@ -501,13 +490,12 @@ export const defaultDDSFuzzSuiteOptions: DDSFuzzSuiteOptions = { * expose at the package level if we want to expose some of the harness's building blocks. */ export function mixinNewClient< - TChannelFactory extends IChannelFactory, TOperation extends BaseOperation, - TState extends DDSFuzzTestState, + TState extends DDSFuzzTestState, >( - model: DDSFuzzModel, + model: DDSFuzzModel, options: DDSFuzzSuiteOptions, -): DDSFuzzModel { +): DDSFuzzModel { const isClientAddOp = (op: TOperation | AddClient): op is AddClient => op.type === "addClient"; @@ -565,18 +553,14 @@ export function mixinNewClient< * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -export function mixinAttach< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, - TState extends DDSFuzzTestState, ->( - model: DDSFuzzModel, +export function mixinAttach( + model: DDSFuzzModel, options: DDSFuzzSuiteOptions, -): DDSFuzzModel { +): DDSFuzzModel { const { numOpsBeforeAttach } = options.detachedStartOptions; if (numOpsBeforeAttach === 0) { // not wrapping the reducer/generator in this case makes stepping through the harness slightly less painful. - return model as DDSFuzzModel; + return model as DDSFuzzModel; } const attachOp = async (): Promise => { return { type: "attach" }; @@ -599,12 +583,12 @@ export function mixinAttach< if (isOperationType("attach", operation)) { state.isDetached = false; assert.equal(state.clients.length, 1); - const clientA: Client = state.clients[0]; + const clientA: Client = state.clients[0]; await clientA.container.attach(createLocalResolverCreateNewRequest("stress test")); const url = await clientA.container.getAbsoluteUrl(""); assert(url !== undefined, "container must have a url"); - const clients: Client[] = await Promise.all( + const clients: Client[] = await Promise.all( Array.from({ length: options.numberOfClients }, async (_, index) => loadClient( state.localDeltaConnectionServer, @@ -620,7 +604,7 @@ export function mixinAttach< // However, now that we're transitioning to an attached state, the summarizer client should never have any edits. // Thus we use one of the clients we just loaded as the summarizer client, and keep the client around that we generated the // attach summary from. - const summarizerClient: Client = clients[0]; + const summarizerClient: Client = clients[0]; clients[0] = state.clients[0]; return { @@ -646,13 +630,12 @@ export function mixinAttach< * expose at the package level if we want to expose some of the harness's building blocks. */ export function mixinSynchronization< - TChannelFactory extends IChannelFactory, TOperation extends BaseOperation, - TState extends DDSFuzzTestState, + TState extends DDSFuzzTestState, >( - model: DDSFuzzModel, + model: DDSFuzzModel, options: DDSFuzzSuiteOptions, -): DDSFuzzModel { +): DDSFuzzModel { const { validationStrategy } = options; let generatorFactory: () => AsyncGenerator; @@ -777,13 +760,12 @@ const isClientSpec = (op: unknown): op is ClientSpec => * expose at the package level if we want to expose some of the harness's building blocks. */ export function mixinClientSelection< - TChannelFactory extends IChannelFactory, TOperation extends BaseOperation, - TState extends DDSFuzzTestState, + TState extends DDSFuzzTestState, >( - model: DDSFuzzModel, + model: DDSFuzzModel, _: DDSFuzzSuiteOptions, -): DDSFuzzModel { +): DDSFuzzModel { const generatorFactory: () => AsyncGenerator = () => { const baseGenerator = model.generatorFactory(); return async (state): Promise => { @@ -826,7 +808,7 @@ export function mixinClientSelection< * * Since the callback is async, this modification to the state could be an issue if multiple runs of this function are done concurrently. */ -async function runInStateWithClient, Result>( +async function runInStateWithClient( state: TState, client: TState["client"], callback: (state: TState) => Promise, @@ -853,12 +835,12 @@ function makeUnreachableCodePathProxy(name: string): T { }); } -async function createDetachedClient( +async function createDetachedClient( localDeltaConnectionServer: ILocalDeltaConnectionServer, codeLoader: ICodeDetailsLoader, codeDetails: IFluidCodeDetails, id: string, -): Promise> { +): Promise { const container = await createDetachedContainer({ codeLoader, documentServiceFactory: new LocalDocumentServiceFactory(localDeltaConnectionServer), @@ -866,19 +848,19 @@ async function createDetachedClient( codeDetails, }); - const newClient: Client = { + const newClient: Client = { container, id, }; return newClient; } -async function loadClient( +async function loadClient( localDeltaConnectionServer: ILocalDeltaConnectionServer, codeLoader: ICodeDetailsLoader, id: string, url: string, -): Promise> { +): Promise { const container = await loadExistingContainer({ documentServiceFactory: new LocalDocumentServiceFactory(localDeltaConnectionServer), request: { url }, @@ -944,15 +926,12 @@ const runtimeFactory: IRuntimeFactory = { * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -export async function runTestForSeed< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, ->( - model: DDSFuzzModel, +export async function runTestForSeed( + model: DDSFuzzModel, options: Omit, seed: number, saveInfo?: SaveInfo, -): Promise> { +): Promise { const random = makeRandom(seed); const startDetached = options.detachedStartOptions.numOpsBeforeAttach !== 0; @@ -984,7 +963,7 @@ export async function runTestForSeed< ), ), ); - const initialState: DDSFuzzTestState = { + const initialState: DDSFuzzTestState = { clients, localDeltaConnectionServer, codeLoader, @@ -1013,8 +992,8 @@ export async function runTestForSeed< return finalState; } -function runTest( - model: DDSFuzzModel, +function runTest( + model: DDSFuzzModel, options: InternalOptions, seed: number, saveInfo: SaveInfo | undefined, @@ -1086,11 +1065,8 @@ export class ReducerPreconditionError extends Error {} * * @internal */ -export async function replayTest< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, ->( - ddsModel: DDSFuzzModel, +export async function replayTest( + ddsModel: DDSFuzzModel, seed: number, operations: TOperation[], saveInfo?: SaveInfo, @@ -1144,11 +1120,8 @@ export function generateTestSeeds(testCount: number, stressMode: StressMode): nu * Creates a suite of eventual consistency tests for a particular DDS model. * @internal */ -export function createDDSFuzzSuite< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, ->( - ddsModel: DDSFuzzModel, +export function createDDSFuzzSuite( + ddsModel: DDSFuzzModel, providedOptions?: Partial, ): void { const options = { @@ -1207,13 +1180,10 @@ export function createDDSFuzzSuite< }); } -const getFullModel = < - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, ->( - ddsModel: DDSFuzzModel, +const getFullModel = ( + ddsModel: DDSFuzzModel, options: DDSFuzzSuiteOptions, -): DDSFuzzModel => +): DDSFuzzModel => mixinAttach( mixinSynchronization( mixinNewClient(mixinClientSelection(ddsModel, options), options), @@ -1242,8 +1212,8 @@ export namespace createDDSFuzzSuite { */ export const only = (...seeds: number[]) => - ( - ddsModel: DDSFuzzModel, + ( + ddsModel: DDSFuzzModel, providedOptions?: Partial, ): void => createDDSFuzzSuite(ddsModel, { @@ -1264,8 +1234,8 @@ export namespace createDDSFuzzSuite { */ export const skip = (...seeds: number[]) => - ( - ddsModel: DDSFuzzModel, + ( + ddsModel: DDSFuzzModel, providedOptions?: Partial, ): void => createDDSFuzzSuite(ddsModel, { diff --git a/packages/test/local-server-stress-tests/src/minification.ts b/packages/test/local-server-stress-tests/src/minification.ts index 965973a84766..472dad78f770 100644 --- a/packages/test/local-server-stress-tests/src/minification.ts +++ b/packages/test/local-server-stress-tests/src/minification.ts @@ -45,16 +45,13 @@ import { ReducerPreconditionError, replayTest } from "./localServerStressHarness */ export type MinimizationTransform = (op: TOperation) => void; -export class FuzzTestMinimizer< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, -> { +export class FuzzTestMinimizer { private initialError?: { message: string; op: BaseOperation }; private readonly transforms: MinimizationTransform[]; private readonly random = makeRandom(); constructor( - readonly ddsModel: DDSFuzzModel, + readonly ddsModel: DDSFuzzModel, readonly providedOptions: Partial, readonly operations: TOperation[], readonly seed: number, From 905f13b7b481367478b4993416f6f89ffcd8db4a Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 28 Jan 2025 14:45:42 -0800 Subject: [PATCH 05/54] it builds and runs --- .../test/local-server-stress-tests/.gitignore | 2 + .../local-server-stress-tests/package.json | 27 ++--- .../src/localServerStressHarness.ts | 42 +++---- .../src/minification.ts | 1 - .../src/test/dirname.cts | 15 +++ .../src/test/localServerStress.spec.ts | 56 +++++++++ .../local-server-stress-tests/src/utils.ts | 107 ------------------ 7 files changed, 104 insertions(+), 146 deletions(-) create mode 100644 packages/test/local-server-stress-tests/src/test/dirname.cts delete mode 100644 packages/test/local-server-stress-tests/src/utils.ts diff --git a/packages/test/local-server-stress-tests/.gitignore b/packages/test/local-server-stress-tests/.gitignore index ee26a5e7bdbf..e4dc83cb7a57 100644 --- a/packages/test/local-server-stress-tests/.gitignore +++ b/packages/test/local-server-stress-tests/.gitignore @@ -50,3 +50,5 @@ nyc # Generated modules intel_modules/ temp_modules/ + +results diff --git a/packages/test/local-server-stress-tests/package.json b/packages/test/local-server-stress-tests/package.json index 3dba45047173..60bea661b2fa 100644 --- a/packages/test/local-server-stress-tests/package.json +++ b/packages/test/local-server-stress-tests/package.json @@ -55,37 +55,39 @@ ], "temp-directory": "nyc/.nyc_output" }, - "devDependencies": { - "@biomejs/biome": "~1.9.3", - "@fluid-experimental/tree": "workspace:~", - "@fluid-internal/mocha-test-setup": "workspace:~", - "@fluid-internal/client-utils": "workspace:~", - "@fluid-private/test-drivers": "workspace:~", + "dependencies": { "@fluid-private/stochastic-test-utils": "workspace:~", + "@fluid-internal/client-utils": "workspace:~", "@fluidframework/aqueduct": "workspace:~", + "@fluidframework/container-loader": "workspace:~", + "@fluidframework/core-utils": "workspace:~", + "@fluidframework/local-driver": "workspace:~", + "@fluidframework/server-local-server": "^5.0.0", + "@fluidframework/test-utils": "workspace:~", + "@fluid-experimental/tree": "workspace:~", + "@fluid-internal/mocha-test-setup": "workspace:~", "@fluidframework/build-common": "^2.0.3", "@fluidframework/build-tools": "^0.51.0", "@fluidframework/container-definitions": "workspace:~", - "@fluidframework/container-loader": "workspace:~", "@fluidframework/container-runtime": "workspace:~", "@fluidframework/container-runtime-definitions": "workspace:~", "@fluidframework/core-interfaces": "workspace:~", - "@fluidframework/core-utils": "workspace:~", "@fluidframework/datastore": "workspace:~", "@fluidframework/datastore-definitions": "workspace:~", "@fluidframework/driver-definitions": "workspace:~", "@fluidframework/driver-utils": "workspace:~", "@fluidframework/eslint-config-fluid": "^5.6.0", "@fluidframework/id-compressor": "workspace:~", - "@fluidframework/local-driver": "workspace:~", "@fluidframework/map": "workspace:~", "@fluidframework/runtime-definitions": "workspace:~", "@fluidframework/runtime-utils": "workspace:~", "@fluidframework/sequence": "workspace:~", - "@fluidframework/server-local-server": "^5.0.0", "@fluidframework/telemetry-utils": "workspace:~", - "@fluidframework/test-utils": "workspace:~", "@fluidframework/tree": "workspace:~", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@biomejs/biome": "~1.9.3", "@types/mocha": "^10.0.10", "@types/node": "^18.19.0", "@types/uuid": "^9.0.2", @@ -97,8 +99,7 @@ "prettier": "~3.0.3", "rimraf": "^4.4.0", "ts-loader": "^9.5.1", - "typescript": "~5.4.5", - "uuid": "^9.0.0" + "typescript": "~5.4.5" }, "fluidBuild": { "tasks": { diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index fe1e7ba313d5..a95722b4b485 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -30,20 +30,7 @@ import { saveOpsToFile, takeAsync, } from "@fluid-private/stochastic-test-utils"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; -import { unreachableCase } from "@fluidframework/core-utils/internal"; -import type { MinimizationTransform } from "./minification.js"; -import { FuzzTestMinimizer } from "./minification.js"; - -import { - createLocalResolverCreateNewRequest, - LocalDocumentServiceFactory, - LocalResolver, -} from "@fluidframework/local-driver/internal"; -import { - ILocalDeltaConnectionServer, - LocalDeltaConnectionServer, -} from "@fluidframework/server-local-server"; +import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal"; import type { ICodeDetailsLoader, IContainer, @@ -55,22 +42,27 @@ import { createDetachedContainer, loadExistingContainer, } from "@fluidframework/container-loader/internal"; -import { LocalCodeLoader } from "@fluidframework/test-utils/internal"; import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; -import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal"; +import { unreachableCase } from "@fluidframework/core-utils/internal"; +import { + createLocalResolverCreateNewRequest, + LocalDocumentServiceFactory, + LocalResolver, +} from "@fluidframework/local-driver/internal"; +import { + ILocalDeltaConnectionServer, + LocalDeltaConnectionServer, +} from "@fluidframework/server-local-server"; +import { LocalCodeLoader } from "@fluidframework/test-utils/internal"; + +import { FuzzTestMinimizer } from "./minification.js"; +import type { MinimizationTransform } from "./minification.js"; const isOperationType = ( type: O["type"], op: BaseOperation, ): op is O => op.type === type; -/** - * @internal - */ -export interface DDSRandom extends IRandom { - handle(): IFluidHandle; -} - export interface Client { container: IContainer; id: string; @@ -714,7 +706,7 @@ export function mixinSynchronization< await Promise.all( connectedClients.map( - (c) => + async (c) => new Promise((resolve) => c.container.isDirty ? c.container.once("saved", () => resolve()) : resolve(), ), @@ -974,7 +966,7 @@ export async function runTestForSeed( options.emitter.emit("testStart", initialState); - let operationCount = 0; + const operationCount = 0; const generator = model.generatorFactory(); const finalState = await performFuzzActionsAsync( generator, diff --git a/packages/test/local-server-stress-tests/src/minification.ts b/packages/test/local-server-stress-tests/src/minification.ts index 472dad78f770..ce1b9d139664 100644 --- a/packages/test/local-server-stress-tests/src/minification.ts +++ b/packages/test/local-server-stress-tests/src/minification.ts @@ -6,7 +6,6 @@ import { TypedEventEmitter } from "@fluid-internal/client-utils"; import type { SaveInfo } from "@fluid-private/stochastic-test-utils"; import { makeRandom } from "@fluid-private/stochastic-test-utils"; -import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; import type { BaseOperation, diff --git a/packages/test/local-server-stress-tests/src/test/dirname.cts b/packages/test/local-server-stress-tests/src/test/dirname.cts new file mode 100644 index 000000000000..ac1703eb418b --- /dev/null +++ b/packages/test/local-server-stress-tests/src/test/dirname.cts @@ -0,0 +1,15 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Problem: + * - `__dirname` is not defined in ESM + * - `import.meta.url` is not defined in CJS + * Solution: + * - Export '__dirname' from a .cjs file in the same directory. + * + * Note that *.cjs files are always CommonJS, but can be imported from ESM. + */ +export const _dirname = __dirname; diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index fa08682e3fe7..7097439305bc 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -2,3 +2,59 @@ * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. */ + +import * as path from "node:path"; + +import { + type AsyncGenerator, + combineReducers, + createWeightedGenerator, + takeAsync, +} from "@fluid-private/stochastic-test-utils"; + +import { + createDDSFuzzSuite, + DDSFuzzModel, + type DDSFuzzTestState, +} from "../localServerStressHarness"; + +import { _dirname } from "./dirname.cjs"; + +interface Noop { + type: "Noop"; +} + +const reducer = combineReducers({ + Noop: () => {}, +}); + +function makeGenerator(): AsyncGenerator { + const syncGenerator = createWeightedGenerator([ + [{ type: "Noop" }, 0.5], + ]); + + return async (state) => syncGenerator(state); +} + +describe("Local Server Stress", () => { + const model: DDSFuzzModel = { + workloadName: "default", + generatorFactory: () => takeAsync(100, makeGenerator()), + reducer: async (state, operation) => reducer(state, operation), + validateConsistency: () => {}, + }; + + createDDSFuzzSuite(model, { + defaultTestCount: 100, + numberOfClients: 3, + clientJoinOptions: { + maxNumberOfClients: 6, + clientAddProbability: 0.1, + stashableClientProbability: 0.2, + }, + reconnectProbability: 0, + // Uncomment to replay a particular seed. + // replay: 0, + saveFailures: { directory: path.join(_dirname, "../../results") }, + }); +}); diff --git a/packages/test/local-server-stress-tests/src/utils.ts b/packages/test/local-server-stress-tests/src/utils.ts deleted file mode 100644 index 68d881db3ca0..000000000000 --- a/packages/test/local-server-stress-tests/src/utils.ts +++ /dev/null @@ -1,107 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { ContainerRuntimeFactoryWithDefaultDataStore } from "@fluidframework/aqueduct/internal"; -import { - type IFluidCodeDetails, - type ILoaderOptions, - type IRuntimeFactory, - ICodeDetailsLoader, -} from "@fluidframework/container-definitions/internal"; -import type { ILoaderProps } from "@fluidframework/container-loader/internal"; -import type { - IDocumentServiceFactory, - IUrlResolver, -} from "@fluidframework/driver-definitions/internal"; -import { - LocalDocumentServiceFactory, - LocalResolver, -} from "@fluidframework/local-driver/internal"; -import { SharedMap } from "@fluidframework/map/internal"; -import type { IFluidDataStoreFactory } from "@fluidframework/runtime-definitions/internal"; -import { ILocalDeltaConnectionServer } from "@fluidframework/server-local-server"; -import { TestFluidObjectFactory, LocalCodeLoader } from "@fluidframework/test-utils/internal"; - -/** - * This allows the input object to be general, - * and the default object to be specific, - * which maintains strong typing for both inputs, and the defaults in the result. - * So if a user specifies a value, that values type will be strongly specified on the Result. - * However if the user does not specify an option input, the result will also get a strong - * type based the default. - */ -export type OptionalToDefault = { - [P in keyof TDefault]: P extends keyof TInput - ? Exclude extends never - ? TDefault[P] - : TInput[P] - : TDefault[P]; -}; - -export interface CreateLoaderParams { - deltaConnectionServer: ILocalDeltaConnectionServer; - codeDetails?: IFluidCodeDetails; - defaultDataStoreFactory?: IFluidDataStoreFactory; - runtimeFactory?: IRuntimeFactory; - codeLoader?: ICodeDetailsLoader; - documentServiceFactory?: IDocumentServiceFactory; - urlResolver?: IUrlResolver; - options?: ILoaderOptions; -} - -export interface CreateLoaderDefaultResults - extends Required> { - documentServiceFactory: LocalDocumentServiceFactory; - urlResolver: LocalResolver; - codeLoader: LocalCodeLoader; - defaultDataStoreFactory: TestFluidObjectFactory; - runtimeFactory: ContainerRuntimeFactoryWithDefaultDataStore; - loaderProps: ILoaderProps; -} - -export function createLoader( - opts: T, -): OptionalToDefault { - const deltaConnectionServer = opts.deltaConnectionServer; - const documentServiceFactory = - opts.documentServiceFactory ?? new LocalDocumentServiceFactory(deltaConnectionServer); - - const urlResolver = opts.urlResolver ?? new LocalResolver(); - - const defaultDataStoreFactory = - opts.defaultDataStoreFactory ?? - new TestFluidObjectFactory([["map", SharedMap.getFactory()]], "default"); - - const runtimeFactory = - opts.runtimeFactory ?? - new ContainerRuntimeFactoryWithDefaultDataStore({ - defaultFactory: defaultDataStoreFactory, - registryEntries: [ - [defaultDataStoreFactory.type, Promise.resolve(defaultDataStoreFactory)], - ], - }); - - const codeDetails = opts.codeDetails ?? { package: "test" }; - - const codeLoader = opts.codeLoader ?? new LocalCodeLoader([[codeDetails, runtimeFactory]]); - - const loaderProps = { - codeLoader, - documentServiceFactory, - urlResolver, - }; - - const rtn: OptionalToDefault = { - deltaConnectionServer, - documentServiceFactory, - urlResolver, - codeDetails, - defaultDataStoreFactory, - runtimeFactory, - codeLoader, - loaderProps, - }; - return rtn as OptionalToDefault; -} From a3485fcd46842983abbf93120b7de728eeaa7d90 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 28 Jan 2025 15:01:14 -0800 Subject: [PATCH 06/54] clean up naming --- .../src/localServerStressHarness.ts | 225 ++++++++---------- .../src/minification.ts | 12 +- .../src/test/localServerStress.spec.ts | 18 +- 3 files changed, 110 insertions(+), 145 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index a95722b4b485..5c1e55f6cba9 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -71,7 +71,7 @@ export interface Client { /** * @internal */ -export interface DDSFuzzTestState extends BaseFuzzTestState { +export interface LocalServerStressState extends BaseFuzzTestState { localDeltaConnectionServer: ILocalDeltaConnectionServer; codeLoader: ICodeDetailsLoader; containerUrl?: string; @@ -139,7 +139,7 @@ function getSavePath(directory: string, model: HasWorkloadName, seed: number): s function getSaveInfo( model: HasWorkloadName, - options: DDSFuzzSuiteOptions, + options: LocalServerStressOptions, seed: number, ): SaveInfo { return { @@ -152,48 +152,9 @@ function getSaveInfo( }; } -/** - * Represents a generic fuzz model for testing eventual consistency of a DDS. - * - * @remarks - * - * Typical DDSes will parameterize this with their SharedObject factory and a serializable set - * of operations corresponding to valid edits in the DDS's public API. - * - * @example - * A simplified SharedString data structure exposing the APIs `insertAt(index, contentString)` and `removeRange(start, end)` - * might represent their API with the following operations: - * ```typescript - * type InsertOperation = { type: "insert"; index: number; content: string } - * type RemoveOperation = { type: "remove"; start: number; end: number } - * type Operation = InsertOperation | RemoveOperation; - * ``` - * - * It would then typically use utilities from \@fluid-private/stochastic-test-utils to write a generator - * for inserting/removing content, and a reducer for interpreting the serializable operations in terms of - * SimpleSharedString's public API. - * - * See \@fluid-private/stochastic-test-utils's README for more details on this step. - * - * Then, it could define a model like so: - * ```typescript - * const model: DDSFuzzModel = { - * workloadName: "insert and delete", - * factory: SimpleSharedStringFactory, - * generatorFactory: myGeneratorFactory, - * reducer: myReducer, - * // A non-toy implementation would typically give a more informative assertion error (e.g. including - * // the IDs for `a` and `b`). - * validateConsistency: (a, b) => { assert.equal(a.channel.getText(), b.channel.getText()); } - * } - * ``` - * This model can be used directly to create a suite of fuzz tests with {@link (createDDSFuzzSuite:function)} - * - * @internal - */ -export interface DDSFuzzModel< +export interface LocalServerStressModel< TOperation extends BaseOperation, - TState extends DDSFuzzTestState = DDSFuzzTestState, + TState extends LocalServerStressState = LocalServerStressState, > { /** * Name for this model. This is used for test case naming, and should generally reflect properties @@ -201,7 +162,7 @@ export interface DDSFuzzModel< * For example, SharedString might fuzz test several different workloads--some involving intervals, * some without, some that never delete text, etc. * This name should also be relatively friendly for file system; if the "save to disk" option of - * {@link (createDDSFuzzSuite:function)} is enabled, it will be kebab cased for failure files. + * {@link (createLocalServerStressSuite:function)} is enabled, it will be kebab cased for failure files. */ workloadName: string; @@ -238,7 +199,7 @@ export interface DDSFuzzModel< /** * @internal */ -export interface DDSFuzzHarnessEvents { +export interface LocalServerStressHarnessEvents { /** * Raised for each non-summarizer client created during fuzz test execution. */ @@ -247,12 +208,12 @@ export interface DDSFuzzHarnessEvents { /** * Raised after creating the initialState but prior to performing the fuzzActions.. */ - (event: "testStart", listener: (initialState: DDSFuzzTestState) => void); + (event: "testStart", listener: (initialState: LocalServerStressState) => void); /** * Raised after all fuzzActions have been completed. */ - (event: "testEnd", listener: (finalState: DDSFuzzTestState) => void); + (event: "testEnd", listener: (finalState: LocalServerStressState) => void); /** * Raised before each generated operation is run by its reducer. @@ -263,7 +224,7 @@ export interface DDSFuzzHarnessEvents { /** * @internal */ -export interface DDSFuzzSuiteOptions { +export interface LocalServerStressOptions { /** * Number of tests to generate for correctness modes (which are run in the PR gate). */ @@ -272,10 +233,10 @@ export interface DDSFuzzSuiteOptions { /** * Number of clients to perform operations on following the attach phase. * This does not include the read-only client created for consistency validation - * and summarization--see {@link DDSFuzzTestState.summarizerClient}. + * and summarization--see {@link LocalServerStressState.summarizerClient}. * - * See {@link DDSFuzzSuiteOptions.detachedStartOptions} for more details on the detached start phase. - * See {@link DDSFuzzSuiteOptions.clientJoinOptions} for more details on clients joining after those in the initial attach. + * See {@link LocalServerStressOptions.detachedStartOptions} for more details on the detached start phase. + * See {@link LocalServerStressOptions.clientJoinOptions} for more details on clients joining after those in the initial attach. */ numberOfClients: number; @@ -303,11 +264,6 @@ export interface DDSFuzzSuiteOptions { * If the current number of clients has reached the maximum, this probability is ignored. */ clientAddProbability: number; - /** - * The probability for an added client to also be stashable which simulates - * getting the pending state, closing the container, and re-opening with the state. - */ - stashableClientProbability?: number; }; /** @@ -316,7 +272,7 @@ export interface DDSFuzzSuiteOptions { * When enabled, the fuzz test starts with a single client generating edits. After a certain number of ops (dictated by `numOpsBeforeAttach`), * an attach op will be generated, at which point: * - getAttachSummary will be invoked on this client - * - The remaining clients (as dictated by {@link DDSFuzzSuiteOptions.numberOfClients}) will load from this summary and join the session + * - The remaining clients (as dictated by {@link LocalServerStressOptions.numberOfClients}) will load from this summary and join the session * * This setup simulates application code initializing state in a data store before attaching it, e.g. running code to edit a DDS from * `DataObject.initializingFirstTime`. @@ -342,20 +298,20 @@ export interface DDSFuzzSuiteOptions { * @example * * ```typescript - * const emitter = new TypedEventEmitter(); + * const emitter = new TypedEventEmitter(); * emitter.on("clientCreate", (client) => { * // Casting is necessary as the event typing isn't parameterized with each DDS type. * const myDDS = client.channel as MyDDSType; * // Do what you want with `myDDS`, e.g. subscribe to change events, add logging, etc. * }); * const options = { - * ...defaultDDSFuzzSuiteOptions, + * ...defaultLocalServerStressSuiteOptions, * emitter, * }; - * createDDSFuzzSuite(model, options); + * createLocalServerStressSuite(model, options); * ``` */ - emitter: TypedEventEmitter; + emitter: TypedEventEmitter; /** * Strategy for validating eventual consistency of DDSes. @@ -402,11 +358,11 @@ export interface DDSFuzzSuiteOptions { * * ```typescript * // Runs only seed 42 for the given model. - * createDDSFuzzSuite(model, { only: [42] }); + * createLocalServerStressSuite(model, { only: [42] }); * ``` * * @remarks - * If you prefer, a variant of the standard `.only` syntax works. See {@link (createDDSFuzzSuite:namespace).only}. + * If you prefer, a variant of the standard `.only` syntax works. See {@link (createLocalServerStressSuite:namespace).only}. */ only: Iterable; @@ -417,11 +373,11 @@ export interface DDSFuzzSuiteOptions { * * ```typescript * // Skips seed 42 for the given model. - * createDDSFuzzSuite(model, { skip: [42] }); + * createLocalServerStressSuite(model, { skip: [42] }); * ``` * * @remarks - * If you prefer, a variant of the standard `.skip` syntax works. See {@link (createDDSFuzzSuite:namespace).skip}. + * If you prefer, a variant of the standard `.skip` syntax works. See {@link (createLocalServerStressSuite:namespace).skip}. */ skip: Iterable; @@ -447,7 +403,7 @@ export interface DDSFuzzSuiteOptions { * exact contents of the test cases. * * Minimization only works when the failure occurs as part of a reducer, and is mostly - * useful if the model being tested defines {@link DDSFuzzModel.minimizationTransforms}. + * useful if the model being tested defines {@link LocalServerStressModel.minimizationTransforms}. * * It can also add a couple seconds of overhead per failing * test case. See {@link MinimizationTransform} for additional context. @@ -458,7 +414,7 @@ export interface DDSFuzzSuiteOptions { /** * @internal */ -export const defaultDDSFuzzSuiteOptions: DDSFuzzSuiteOptions = { +export const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { defaultTestCount: defaultOptions.defaultTestCount, detachedStartOptions: { numOpsBeforeAttach: 5, @@ -483,11 +439,11 @@ export const defaultDDSFuzzSuiteOptions: DDSFuzzSuiteOptions = { */ export function mixinNewClient< TOperation extends BaseOperation, - TState extends DDSFuzzTestState, + TState extends LocalServerStressState, >( - model: DDSFuzzModel, - options: DDSFuzzSuiteOptions, -): DDSFuzzModel { + model: LocalServerStressModel, + options: LocalServerStressOptions, +): LocalServerStressModel { const isClientAddOp = (op: TOperation | AddClient): op is AddClient => op.type === "addClient"; @@ -545,14 +501,17 @@ export function mixinNewClient< * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -export function mixinAttach( - model: DDSFuzzModel, - options: DDSFuzzSuiteOptions, -): DDSFuzzModel { +export function mixinAttach< + TOperation extends BaseOperation, + TState extends LocalServerStressState, +>( + model: LocalServerStressModel, + options: LocalServerStressOptions, +): LocalServerStressModel { const { numOpsBeforeAttach } = options.detachedStartOptions; if (numOpsBeforeAttach === 0) { // not wrapping the reducer/generator in this case makes stepping through the harness slightly less painful. - return model as DDSFuzzModel; + return model as LocalServerStressModel; } const attachOp = async (): Promise => { return { type: "attach" }; @@ -585,8 +544,8 @@ export function mixinAttach( - model: DDSFuzzModel, - options: DDSFuzzSuiteOptions, -): DDSFuzzModel { + model: LocalServerStressModel, + options: LocalServerStressOptions, +): LocalServerStressModel { const { validationStrategy } = options; let generatorFactory: () => AsyncGenerator; @@ -744,8 +703,8 @@ const isClientSpec = (op: unknown): op is ClientSpec => /** * Mixes in the ability to select a client to perform an operation on. - * Makes this available to existing generators and reducers in the passed-in model via {@link DDSFuzzTestState.client} - * and {@link @fluid-private/test-dds-utils#DDSFuzzTestState.channel}. + * Makes this available to existing generators and reducers in the passed-in model via {@link LocalServerStressState.client} + * and {@link @fluid-private/test-dds-utils#LocalServerStressTestState.channel}. * * @remarks This exists purely for convenience, as "pick a client to perform an operation on" is a common concern. * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to @@ -753,11 +712,11 @@ const isClientSpec = (op: unknown): op is ClientSpec => */ export function mixinClientSelection< TOperation extends BaseOperation, - TState extends DDSFuzzTestState, + TState extends LocalServerStressState, >( - model: DDSFuzzModel, - _: DDSFuzzSuiteOptions, -): DDSFuzzModel { + model: LocalServerStressModel, + _: LocalServerStressOptions, +): LocalServerStressModel { const generatorFactory: () => AsyncGenerator = () => { const baseGenerator = model.generatorFactory(); return async (state): Promise => { @@ -800,7 +759,7 @@ export function mixinClientSelection< * * Since the callback is async, this modification to the state could be an issue if multiple runs of this function are done concurrently. */ -async function runInStateWithClient( +async function runInStateWithClient( state: TState, client: TState["client"], callback: (state: TState) => Promise, @@ -919,11 +878,11 @@ const runtimeFactory: IRuntimeFactory = { * expose at the package level if we want to expose some of the harness's building blocks. */ export async function runTestForSeed( - model: DDSFuzzModel, - options: Omit, + model: LocalServerStressModel, + options: Omit, seed: number, saveInfo?: SaveInfo, -): Promise { +): Promise { const random = makeRandom(seed); const startDetached = options.detachedStartOptions.numOpsBeforeAttach !== 0; @@ -938,24 +897,26 @@ export async function runTestForSeed( codeDetails, startDetached ? makeFriendlyClientId(random, 0) : "summarizer", ); + const clients: Client[] = []; if (!startDetached) { await initialClient.container.attach(createLocalResolverCreateNewRequest("stress")); + const url = await initialClient.container.getAbsoluteUrl(""); + assert(url !== undefined, "attached container must have url"); + await Promise.all( + Array.from({ length: options.numberOfClients }, async (_, i) => + loadClient( + localDeltaConnectionServer, + codeLoader, + makeFriendlyClientId(random, i), + url, + ), + ), + ); + } else { + clients.push(initialClient); } - const url = "aas"; - const clients = startDetached - ? [initialClient] - : await Promise.all( - Array.from({ length: options.numberOfClients }, async (_, i) => - loadClient( - localDeltaConnectionServer, - codeLoader, - makeFriendlyClientId(random, i), - url, - ), - ), - ); - const initialState: DDSFuzzTestState = { + const initialState: LocalServerStressState = { clients, localDeltaConnectionServer, codeLoader, @@ -966,11 +927,15 @@ export async function runTestForSeed( options.emitter.emit("testStart", initialState); - const operationCount = 0; + let operationCount = 0; const generator = model.generatorFactory(); const finalState = await performFuzzActionsAsync( generator, - model.reducer, + async (state, operation) => { + options.emitter.emit("operation"); + operationCount++; + return model.reducer(state, operation); + }, initialState, saveInfo, ); @@ -985,7 +950,7 @@ export async function runTestForSeed( } function runTest( - model: DDSFuzzModel, + model: LocalServerStressModel, options: InternalOptions, seed: number, saveInfo: SaveInfo | undefined, @@ -1033,12 +998,12 @@ function runTest( }); } -type InternalOptions = Omit & { +type InternalOptions = Omit & { only: Set; skip: Set; }; -function isInternalOptions(options: DDSFuzzSuiteOptions): options is InternalOptions { +function isInternalOptions(options: LocalServerStressOptions): options is InternalOptions { return options.only instanceof Set && options.skip instanceof Set; } @@ -1058,14 +1023,14 @@ export class ReducerPreconditionError extends Error {} * @internal */ export async function replayTest( - ddsModel: DDSFuzzModel, + ddsModel: LocalServerStressModel, seed: number, operations: TOperation[], saveInfo?: SaveInfo, - providedOptions?: Partial, + providedOptions?: Partial, ): Promise { const options = { - ...defaultDDSFuzzSuiteOptions, + ...defaultLocalServerStressSuiteOptions, ...providedOptions, only: new Set(providedOptions?.only ?? []), skip: new Set(providedOptions?.skip ?? []), @@ -1112,12 +1077,12 @@ export function generateTestSeeds(testCount: number, stressMode: StressMode): nu * Creates a suite of eventual consistency tests for a particular DDS model. * @internal */ -export function createDDSFuzzSuite( - ddsModel: DDSFuzzModel, - providedOptions?: Partial, +export function createLocalServerStressSuite( + ddsModel: LocalServerStressModel, + providedOptions?: Partial, ): void { const options = { - ...defaultDDSFuzzSuiteOptions, + ...defaultLocalServerStressSuiteOptions, ...providedOptions, }; @@ -1173,9 +1138,9 @@ export function createDDSFuzzSuite( } const getFullModel = ( - ddsModel: DDSFuzzModel, - options: DDSFuzzSuiteOptions, -): DDSFuzzModel => + ddsModel: LocalServerStressModel, + options: LocalServerStressOptions, +): LocalServerStressModel => mixinAttach( mixinSynchronization( mixinNewClient(mixinClientSelection(ddsModel, options), options), @@ -1185,12 +1150,12 @@ const getFullModel = ( ); /** - * {@inheritDoc (createDDSFuzzSuite:function)} + * {@inheritDoc (createLocalServerStressSuite:function)} * @internal */ // Explicit usage of namespace needed for api-extractor. // eslint-disable-next-line @typescript-eslint/no-namespace -export namespace createDDSFuzzSuite { +export namespace createLocalServerStressSuite { /** * Runs only the provided seeds. * @@ -1198,17 +1163,17 @@ export namespace createDDSFuzzSuite { * * ```typescript * // Runs only seed 42 for the given model. - * createDDSFuzzSuite.only(42)(model); + * createLocalServerStressSuite.only(42)(model); * ``` * @internal */ export const only = (...seeds: number[]) => ( - ddsModel: DDSFuzzModel, - providedOptions?: Partial, + ddsModel: LocalServerStressModel, + providedOptions?: Partial, ): void => - createDDSFuzzSuite(ddsModel, { + createLocalServerStressSuite(ddsModel, { ...providedOptions, only: [...seeds, ...(providedOptions?.only ?? [])], }); @@ -1220,17 +1185,17 @@ export namespace createDDSFuzzSuite { * * ```typescript * // Skips seed 42 for the given model. - * createDDSFuzzSuite.skip(42)(model); + * createLocalServerStressSuite.skip(42)(model); * ``` * @internal */ export const skip = (...seeds: number[]) => ( - ddsModel: DDSFuzzModel, - providedOptions?: Partial, + ddsModel: LocalServerStressModel, + providedOptions?: Partial, ): void => - createDDSFuzzSuite(ddsModel, { + createLocalServerStressSuite(ddsModel, { ...providedOptions, skip: [...seeds, ...(providedOptions?.skip ?? [])], }); diff --git a/packages/test/local-server-stress-tests/src/minification.ts b/packages/test/local-server-stress-tests/src/minification.ts index ce1b9d139664..f0e2a5c6ac6f 100644 --- a/packages/test/local-server-stress-tests/src/minification.ts +++ b/packages/test/local-server-stress-tests/src/minification.ts @@ -9,9 +9,9 @@ import { makeRandom } from "@fluid-private/stochastic-test-utils"; import type { BaseOperation, - DDSFuzzHarnessEvents, - DDSFuzzModel, - DDSFuzzSuiteOptions, + LocalServerStressHarnessEvents, + LocalServerStressModel, + LocalServerStressOptions, } from "./localServerStressHarness.js"; import { ReducerPreconditionError, replayTest } from "./localServerStressHarness.js"; @@ -50,8 +50,8 @@ export class FuzzTestMinimizer { private readonly random = makeRandom(); constructor( - readonly ddsModel: DDSFuzzModel, - readonly providedOptions: Partial, + readonly ddsModel: LocalServerStressModel, + readonly providedOptions: Partial, readonly operations: TOperation[], readonly seed: number, readonly saveInfo: SaveInfo, @@ -192,7 +192,7 @@ export class FuzzTestMinimizer { */ private async assertFails(): Promise { const emitter = (this.providedOptions.emitter ??= - new TypedEventEmitter()); + new TypedEventEmitter()); let lastOp: BaseOperation = { type: "___none___" }; const lastOpTracker = (op: BaseOperation): void => { diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 7097439305bc..c7755676ab1d 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -13,9 +13,9 @@ import { } from "@fluid-private/stochastic-test-utils"; import { - createDDSFuzzSuite, - DDSFuzzModel, - type DDSFuzzTestState, + createLocalServerStressSuite, + LocalServerStressModel, + type LocalServerStressState, } from "../localServerStressHarness"; import { _dirname } from "./dirname.cjs"; @@ -24,12 +24,12 @@ interface Noop { type: "Noop"; } -const reducer = combineReducers({ +const reducer = combineReducers({ Noop: () => {}, }); -function makeGenerator(): AsyncGenerator { - const syncGenerator = createWeightedGenerator([ +function makeGenerator(): AsyncGenerator { + const syncGenerator = createWeightedGenerator([ [{ type: "Noop" }, 0.5], ]); @@ -37,24 +37,24 @@ function makeGenerator(): AsyncGenerator { } describe("Local Server Stress", () => { - const model: DDSFuzzModel = { + const model: LocalServerStressModel = { workloadName: "default", generatorFactory: () => takeAsync(100, makeGenerator()), reducer: async (state, operation) => reducer(state, operation), validateConsistency: () => {}, }; - createDDSFuzzSuite(model, { + createLocalServerStressSuite(model, { defaultTestCount: 100, numberOfClients: 3, clientJoinOptions: { maxNumberOfClients: 6, clientAddProbability: 0.1, - stashableClientProbability: 0.2, }, reconnectProbability: 0, // Uncomment to replay a particular seed. // replay: 0, saveFailures: { directory: path.join(_dirname, "../../results") }, + saveSuccesses: { directory: path.join(_dirname, "../../results") }, }); }); From 9caeda68af2501baf1410ffbfbbc5fe95dd6f0b8 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 28 Jan 2025 16:43:19 -0800 Subject: [PATCH 07/54] cleanup exports --- .../src/localServerStressHarness.ts | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 5c1e55f6cba9..81e6781b4fb2 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -63,7 +63,7 @@ const isOperationType = ( op: BaseOperation, ): op is O => op.type === type; -export interface Client { +interface Client { container: IContainer; id: string; } @@ -86,7 +86,7 @@ export interface LocalServerStressState extends BaseFuzzTestState { /** * @internal */ -export interface ClientSpec { +interface SelectedClientSpec { clientId: string; } @@ -100,14 +100,14 @@ export interface BaseOperation { /** * @internal */ -export interface Attach { +interface Attach { type: "attach"; } /** * @internal */ -export interface AddClient { +interface AddClient { type: "addClient"; id: string; url: string; @@ -116,7 +116,7 @@ export interface AddClient { /** * @internal */ -export interface Synchronize { +interface Synchronize { type: "synchronize"; clients?: Client[]; } @@ -414,7 +414,7 @@ export interface LocalServerStressOptions { /** * @internal */ -export const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { +const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { defaultTestCount: defaultOptions.defaultTestCount, detachedStartOptions: { numOpsBeforeAttach: 5, @@ -437,7 +437,7 @@ export const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -export function mixinNewClient< +function mixinNewClient< TOperation extends BaseOperation, TState extends LocalServerStressState, >( @@ -501,10 +501,7 @@ export function mixinNewClient< * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -export function mixinAttach< - TOperation extends BaseOperation, - TState extends LocalServerStressState, ->( +function mixinAttach( model: LocalServerStressModel, options: LocalServerStressOptions, ): LocalServerStressModel { @@ -580,7 +577,7 @@ export function mixinAttach< * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -export function mixinSynchronization< +function mixinSynchronization< TOperation extends BaseOperation, TState extends LocalServerStressState, >( @@ -698,8 +695,8 @@ export function mixinSynchronization< }; } -const isClientSpec = (op: unknown): op is ClientSpec => - (op as ClientSpec).clientId !== undefined; +const hasSelectedClientSpec = (op: unknown): op is SelectedClientSpec => + (op as SelectedClientSpec).clientId !== undefined; /** * Mixes in the ability to select a client to perform an operation on. @@ -710,7 +707,7 @@ const isClientSpec = (op: unknown): op is ClientSpec => * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -export function mixinClientSelection< +function mixinClientSelection< TOperation extends BaseOperation, TState extends LocalServerStressState, >( @@ -739,7 +736,7 @@ export function mixinClientSelection< }; const reducer: AsyncReducer = async (state, operation) => { - assert(isClientSpec(operation), "operation should have been given a client"); + assert(hasSelectedClientSpec(operation), "operation should have been given a client"); const client = state.clients.find((c) => c.id === operation.clientId); assert(client !== undefined); await runInStateWithClient(state, client, async () => @@ -877,7 +874,7 @@ const runtimeFactory: IRuntimeFactory = { * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -export async function runTestForSeed( +async function runTestForSeed( model: LocalServerStressModel, options: Omit, seed: number, @@ -1048,7 +1045,7 @@ export async function replayTest( await runTestForSeed(model, options, seed, saveInfo); } -export function generateTestSeeds(testCount: number, stressMode: StressMode): number[] { +function generateTestSeeds(testCount: number, stressMode: StressMode): number[] { switch (stressMode) { case StressMode.Short: case StressMode.Normal: { From 7a87d9da5175f8a791fc93df1d02878664733e38 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 28 Jan 2025 18:17:27 -0800 Subject: [PATCH 08/54] build out the defaultStressDataObjectFactory --- .../src/localServerStressHarness.ts | 91 +++++++++++++++++-- 1 file changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 81e6781b4fb2..d5ec3ac654ec 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -7,7 +7,7 @@ import { strict as assert } from "node:assert"; import { mkdirSync, readFileSync } from "node:fs"; import path from "node:path"; -import { TypedEventEmitter } from "@fluid-internal/client-utils"; +import { stringToBuffer, TypedEventEmitter } from "@fluid-internal/client-utils"; import type { AsyncGenerator, AsyncReducer, @@ -43,12 +43,16 @@ import { loadExistingContainer, } from "@fluidframework/container-loader/internal"; import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; +import type { IFluidHandle } from "@fluidframework/core-interfaces"; +import type { FluidObject } from "@fluidframework/core-interfaces"; import { unreachableCase } from "@fluidframework/core-utils/internal"; import { createLocalResolverCreateNewRequest, LocalDocumentServiceFactory, LocalResolver, } from "@fluidframework/local-driver/internal"; +import type { ISharedDirectory } from "@fluidframework/map/internal"; +import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { ILocalDeltaConnectionServer, LocalDeltaConnectionServer, @@ -66,6 +70,7 @@ const isOperationType = ( interface Client { container: IContainer; id: string; + entryPoint: StressDataObject; } /** @@ -75,9 +80,7 @@ export interface LocalServerStressState extends BaseFuzzTestState { localDeltaConnectionServer: ILocalDeltaConnectionServer; codeLoader: ICodeDetailsLoader; containerUrl?: string; - random: IRandom; - clients: Client[]; client: Client; isDetached: boolean; @@ -767,7 +770,7 @@ async function runInStateWithClient | undefined = await container.getEntryPoint(); + assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); + const newClient: Client = { container, id, + entryPoint: maybe.StressDataObject, }; return newClient; } @@ -816,9 +823,13 @@ async function loadClient( codeLoader, }); + const maybe: FluidObject | undefined = await container.getEntryPoint(); + assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); + return { container, id, + entryPoint: maybe.StressDataObject, }; } @@ -835,15 +846,75 @@ class StressDataObject extends DataObject { get StressDataObject() { return this; } + + public globalObjects: Record< + string, + | { type: "newBlob"; blobHandle: IFluidHandle } + | { type: "newDatastore"; dataStore: IDataStore } + | { + type: "stressDataObject"; + StressDataObject: StressDataObject; + channels: { root: ISharedDirectory }; + } + | { type: "newAlias"; alias: string } + > = {}; + + protected async getDefaultStressDataObject() { + const root = await this.context.containerRuntime.getAliasedDataStoreEntryPoint("default"); + assert(root !== undefined, "default must exist"); + + const maybe: FluidObject | undefined = await root.get(); + assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); + return maybe.StressDataObject; + } + + protected async initializingFromExisting(): Promise { + const root = await this.getDefaultStressDataObject(); + + this.globalObjects = root.globalObjects; + + this.globalObjects[this.id] = { + type: "stressDataObject", + StressDataObject: this, + channels: { root: this.root }, + }; + } + + public uploadBlob(id: string, contents: string) { + void this.runtime + .uploadBlob(stringToBuffer(contents, "utf-8")) + .then((blobHandle) => (this.globalObjects[id] = { type: "newBlob", blobHandle })); + } + + public createDataStore(id: string) { + void this.context.containerRuntime + .createDataStore(stressDataObjectFactory.type) + .then(async (dataStore) => { + this.globalObjects[id] = { type: "newDatastore", dataStore }; + }); + } } const stressDataObjectFactory = new DataObjectFactory( - "ParentDataObject", + "StressDataObject", StressDataObject, undefined, {}, ); +class DefaultStressDataObject extends StressDataObject { + protected override async getDefaultStressDataObject(): Promise { + return this; + } +} + +const defaultStressDataObjectFactory = new DataObjectFactory( + "DefaultStressDataObject", + DefaultStressDataObject, + undefined, + {}, +); + const runtimeFactory: IRuntimeFactory = { get IRuntimeFactory() { return this; @@ -853,17 +924,23 @@ const runtimeFactory: IRuntimeFactory = { context, existing, registryEntries: [ + [defaultStressDataObjectFactory.type, Promise.resolve(defaultStressDataObjectFactory)], [stressDataObjectFactory.type, Promise.resolve(stressDataObjectFactory)], ], provideEntryPoint: async (rt) => { const maybeRoot = await rt.getAliasedDataStoreEntryPoint("default"); if (maybeRoot === undefined) { - const ds = await rt.createDataStore(stressDataObjectFactory.type); + const ds = await rt.createDataStore(defaultStressDataObjectFactory.type); await ds.trySetAlias("default"); } const root = await rt.getAliasedDataStoreEntryPoint("default"); assert(root !== undefined, "default must exist"); - return root.get(); + + const maybe: FluidObject | undefined = await root.get(); + assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); + + maybe.StressDataObject.globalObjects.default = { type: "newAlias", alias: "default" }; + return maybe; }, }); }, From 71ea73cf86b107d2c48be07785c2ae54dfa710c4 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 29 Jan 2025 12:44:52 -0800 Subject: [PATCH 09/54] add some workloads --- .../src/localServerStressHarness.ts | 50 +++++++++----- .../src/test/localServerStress.spec.ts | 65 ++++++++++++++++--- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index d5ec3ac654ec..c5e0a022cf7e 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -67,7 +67,7 @@ const isOperationType = ( op: BaseOperation, ): op is O => op.type === type; -interface Client { +export interface Client { container: IContainer; id: string; entryPoint: StressDataObject; @@ -420,7 +420,7 @@ export interface LocalServerStressOptions { const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { defaultTestCount: defaultOptions.defaultTestCount, detachedStartOptions: { - numOpsBeforeAttach: 5, + numOpsBeforeAttach: 0, }, handleGenerationDisabled: true, emitter: new TypedEventEmitter(), @@ -842,12 +842,12 @@ function makeFriendlyClientId(random: IRandom, index: number): string { return index < 26 ? String.fromCodePoint(index + 65) : random.uuid4(); } -class StressDataObject extends DataObject { +export class StressDataObject extends DataObject { get StressDataObject() { return this; } - public globalObjects: Record< + private _globalObjects: Record< string, | { type: "newBlob"; blobHandle: IFluidHandle } | { type: "newDatastore"; dataStore: IDataStore } @@ -859,6 +859,22 @@ class StressDataObject extends DataObject { | { type: "newAlias"; alias: string } > = {}; + public get globalObjects(): Readonly< + Record< + string, + | { type: "newBlob"; blobHandle: IFluidHandle } + | { type: "newDatastore"; dataStore: IDataStore } + | { + type: "stressDataObject"; + StressDataObject: StressDataObject; + channels: { root: ISharedDirectory }; + } + | { type: "newAlias"; alias: string } + > + > { + return this._globalObjects; + } + protected async getDefaultStressDataObject() { const root = await this.context.containerRuntime.getAliasedDataStoreEntryPoint("default"); assert(root !== undefined, "default must exist"); @@ -871,9 +887,9 @@ class StressDataObject extends DataObject { protected async initializingFromExisting(): Promise { const root = await this.getDefaultStressDataObject(); - this.globalObjects = root.globalObjects; + this._globalObjects = root._globalObjects; - this.globalObjects[this.id] = { + this._globalObjects[this.id] = { type: "stressDataObject", StressDataObject: this, channels: { root: this.root }, @@ -883,14 +899,14 @@ class StressDataObject extends DataObject { public uploadBlob(id: string, contents: string) { void this.runtime .uploadBlob(stringToBuffer(contents, "utf-8")) - .then((blobHandle) => (this.globalObjects[id] = { type: "newBlob", blobHandle })); + .then((blobHandle) => (this._globalObjects[id] = { type: "newBlob", blobHandle })); } public createDataStore(id: string) { void this.context.containerRuntime .createDataStore(stressDataObjectFactory.type) .then(async (dataStore) => { - this.globalObjects[id] = { type: "newDatastore", dataStore }; + this._globalObjects[id] = { type: "newDatastore", dataStore }; }); } } @@ -976,15 +992,17 @@ async function runTestForSeed( await initialClient.container.attach(createLocalResolverCreateNewRequest("stress")); const url = await initialClient.container.getAbsoluteUrl(""); assert(url !== undefined, "attached container must have url"); - await Promise.all( - Array.from({ length: options.numberOfClients }, async (_, i) => - loadClient( - localDeltaConnectionServer, - codeLoader, - makeFriendlyClientId(random, i), - url, + clients.push( + ...(await Promise.all( + Array.from({ length: options.numberOfClients }, async (_, i) => + loadClient( + localDeltaConnectionServer, + codeLoader, + makeFriendlyClientId(random, i), + url, + ), ), - ), + )), ); } else { clients.push(initialClient); diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index c7755676ab1d..4c66e83f3e16 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -7,10 +7,12 @@ import * as path from "node:path"; import { type AsyncGenerator, - combineReducers, + Generator, + combineReducersAsync, createWeightedGenerator, takeAsync, } from "@fluid-private/stochastic-test-utils"; +import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { createLocalServerStressSuite, @@ -19,25 +21,70 @@ import { } from "../localServerStressHarness"; import { _dirname } from "./dirname.cjs"; +import { assert } from "@fluidframework/core-utils/internal"; -interface Noop { - type: "Noop"; +interface UploadBlob { + type: "uploadBlob"; } +interface AliasDataStore { + type: "aliasDataStore"; + id: string; +} +interface CreateDataStore { + type: "createDataStore"; +} + +type StressOperations = UploadBlob | AliasDataStore | CreateDataStore; -const reducer = combineReducers({ - Noop: () => {}, +const reducer = combineReducersAsync({ + aliasDataStore: async (state, op) => { + const entry = state.client.entryPoint.globalObjects[op.id]; + assert(entry.type === "newDatastore", "must be a new datastore"); + + await entry.dataStore.trySetAlias(String.fromCodePoint(state.random.integer(0, 26) + 65)); + }, + createDataStore: async (state) => { + state.client.entryPoint.createDataStore(state.random.uuid4()); + }, + uploadBlob: async (state) => { + state.client.entryPoint.uploadBlob( + state.random.uuid4(), + state.random.string(state.random.integer(1, 246)), + ); + }, }); -function makeGenerator(): AsyncGenerator { - const syncGenerator = createWeightedGenerator([ - [{ type: "Noop" }, 0.5], +function makeGenerator(): AsyncGenerator { + const aliasDataStore: Generator = (state) => { + const newDataStores = Object.entries(state.client.entryPoint.globalObjects).filter( + (e): e is [string, { type: "newDatastore"; dataStore: IDataStore }] => + e[1].type === "newDatastore", + ); + const [id] = state.random.pick(newDataStores); + return { + type: "aliasDataStore", + id, + } satisfies AliasDataStore; + }; + + const syncGenerator = createWeightedGenerator([ + [ + aliasDataStore, + 1, + (state) => + Object.values(state.client.entryPoint.globalObjects).some( + (v) => v.type === "newDatastore", + ), + ], + [{ type: "createDataStore" }, 1], + [{ type: "uploadBlob" }, 1], ]); return async (state) => syncGenerator(state); } describe("Local Server Stress", () => { - const model: LocalServerStressModel = { + const model: LocalServerStressModel = { workloadName: "default", generatorFactory: () => takeAsync(100, makeGenerator()), reducer: async (state, operation) => reducer(state, operation), From b0ece924caf6c3fce9487f0391854ee146fe2d62 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 29 Jan 2025 15:37:59 -0800 Subject: [PATCH 10/54] integrate map model --- packages/dds/map/package.json | 10 ++ packages/dds/map/src/test/index.ts | 5 + packages/dds/map/src/test/mocha/index.ts | 5 + .../dds/map/src/test/mocha/map.fuzz.spec.ts | 19 +-- packages/dds/map/src/test/tsconfig.json | 4 + .../src/localServerStressHarness.ts | 64 +++++++---- .../src/test/localServerStress.spec.ts | 108 +++++++++++++++--- 7 files changed, 165 insertions(+), 50 deletions(-) create mode 100644 packages/dds/map/src/test/index.ts create mode 100644 packages/dds/map/src/test/mocha/index.ts diff --git a/packages/dds/map/package.json b/packages/dds/map/package.json index 622e54187694..ed72b9d7298c 100644 --- a/packages/dds/map/package.json +++ b/packages/dds/map/package.json @@ -42,6 +42,16 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" } + }, + "./internal/test": { + "import": { + "types": "./lib/test/index.d.ts", + "default": "./lib/test/index.js" + }, + "require": { + "types": "./dist/test/index.d.ts", + "default": "./dist/test/index.js" + } } }, "main": "lib/index.js", diff --git a/packages/dds/map/src/test/index.ts b/packages/dds/map/src/test/index.ts new file mode 100644 index 000000000000..098e8522c2b1 --- /dev/null +++ b/packages/dds/map/src/test/index.ts @@ -0,0 +1,5 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ +export { model } from "./mocha/index.js"; diff --git a/packages/dds/map/src/test/mocha/index.ts b/packages/dds/map/src/test/mocha/index.ts new file mode 100644 index 000000000000..1145af36f37b --- /dev/null +++ b/packages/dds/map/src/test/mocha/index.ts @@ -0,0 +1,5 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ +export { model } from "./map.fuzz.spec.js"; diff --git a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts index 07236a8bbc18..0ab5c6a24f94 100644 --- a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts +++ b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts @@ -130,15 +130,18 @@ function makeGenerator( return async (state) => syncGenerator(state); } -describe("Map fuzz tests", () => { - const model: DDSFuzzModel = { - workloadName: "default", - factory: new MapFactory(), - generatorFactory: () => takeAsync(100, makeGenerator()), - reducer: async (state, operation) => reducer(state, operation), - validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), - }; +/** + * the maps fuzz model + */ +export const model: DDSFuzzModel = { + workloadName: "default", + factory: new MapFactory(), + generatorFactory: () => takeAsync(100, makeGenerator()), + reducer: async (state, operation) => reducer(state, operation), + validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), +}; +describe.skip("Map fuzz tests", () => { createDDSFuzzSuite(model, { defaultTestCount: 100, numberOfClients: 3, diff --git a/packages/dds/map/src/test/tsconfig.json b/packages/dds/map/src/test/tsconfig.json index ba735163526f..2080b3ab30c9 100644 --- a/packages/dds/map/src/test/tsconfig.json +++ b/packages/dds/map/src/test/tsconfig.json @@ -7,6 +7,10 @@ "noUnusedLocals": false, // Need it so memory tests can declare local variables just for the sake of keeping things in memory "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, + // The sequence package uses test code from merge-tree, so we need to build types for test files, which we typically + // don't do. + "declaration": true, + "declarationMap": true, }, "include": ["./**/*"], "references": [ diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index c5e0a022cf7e..cd65f5daa34b 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -46,12 +46,12 @@ import { loadContainerRuntime } from "@fluidframework/container-runtime/internal import type { IFluidHandle } from "@fluidframework/core-interfaces"; import type { FluidObject } from "@fluidframework/core-interfaces"; import { unreachableCase } from "@fluidframework/core-utils/internal"; +import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import { createLocalResolverCreateNewRequest, LocalDocumentServiceFactory, LocalResolver, } from "@fluidframework/local-driver/internal"; -import type { ISharedDirectory } from "@fluidframework/map/internal"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { ILocalDeltaConnectionServer, @@ -775,7 +775,7 @@ async function runInStateWithClient(name: string): T { +export function makeUnreachableCodePathProxy(name: string): T { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return new Proxy({} as T, { get: (): never => { @@ -847,14 +847,14 @@ export class StressDataObject extends DataObject { return this; } - private _globalObjects: Record< + protected _globalObjects: Record< string, - | { type: "newBlob"; blobHandle: IFluidHandle } - | { type: "newDatastore"; dataStore: IDataStore } + | { type: "newBlob"; handle: IFluidHandle } + | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } | { type: "stressDataObject"; StressDataObject: StressDataObject; - channels: { root: ISharedDirectory }; + handle: IFluidHandle; } | { type: "newAlias"; alias: string } > = {}; @@ -862,14 +862,14 @@ export class StressDataObject extends DataObject { public get globalObjects(): Readonly< Record< string, - | { type: "newBlob"; blobHandle: IFluidHandle } - | { type: "newDatastore"; dataStore: IDataStore } + | { type: "newBlob"; handle: IFluidHandle } + | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } | { type: "stressDataObject"; StressDataObject: StressDataObject; - channels: { root: ISharedDirectory }; + handle: IFluidHandle; } - | { type: "newAlias"; alias: string } + | { type: "newAlias"; alias: string; handle?: undefined } > > { return this._globalObjects; @@ -884,7 +884,11 @@ export class StressDataObject extends DataObject { return maybe.StressDataObject; } - protected async initializingFromExisting(): Promise { + public channels: Record IChannel> = { + root: () => this.root, + }; + + protected async preInitialize(): Promise { const root = await this.getDefaultStressDataObject(); this._globalObjects = root._globalObjects; @@ -892,21 +896,27 @@ export class StressDataObject extends DataObject { this._globalObjects[this.id] = { type: "stressDataObject", StressDataObject: this, - channels: { root: this.root }, + handle: this.handle, }; } public uploadBlob(id: string, contents: string) { void this.runtime .uploadBlob(stringToBuffer(contents, "utf-8")) - .then((blobHandle) => (this._globalObjects[id] = { type: "newBlob", blobHandle })); + .then( + (blobHandle) => (this._globalObjects[id] = { type: "newBlob", handle: blobHandle }), + ); } public createDataStore(id: string) { void this.context.containerRuntime .createDataStore(stressDataObjectFactory.type) .then(async (dataStore) => { - this._globalObjects[id] = { type: "newDatastore", dataStore }; + this._globalObjects[id] = { + type: "newDatastore", + dataStore, + handle: dataStore.entryPoint, + }; }); } } @@ -919,9 +929,16 @@ const stressDataObjectFactory = new DataObjectFactory( ); class DefaultStressDataObject extends StressDataObject { + public static readonly alias = "default"; + protected override async getDefaultStressDataObject(): Promise { return this; } + + protected async preInitialize(): Promise { + await super.preInitialize(); + this._globalObjects.default = { type: "newAlias", alias: DefaultStressDataObject.alias }; + } } const defaultStressDataObjectFactory = new DataObjectFactory( @@ -944,18 +961,19 @@ const runtimeFactory: IRuntimeFactory = { [stressDataObjectFactory.type, Promise.resolve(stressDataObjectFactory)], ], provideEntryPoint: async (rt) => { - const maybeRoot = await rt.getAliasedDataStoreEntryPoint("default"); - if (maybeRoot === undefined) { + const maybeDefault = await rt.getAliasedDataStoreEntryPoint( + DefaultStressDataObject.alias, + ); + if (maybeDefault === undefined) { const ds = await rt.createDataStore(defaultStressDataObjectFactory.type); - await ds.trySetAlias("default"); + await ds.trySetAlias(DefaultStressDataObject.alias); } - const root = await rt.getAliasedDataStoreEntryPoint("default"); - assert(root !== undefined, "default must exist"); - - const maybe: FluidObject | undefined = await root.get(); - assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); + const aliasedDefault = await rt.getAliasedDataStoreEntryPoint( + DefaultStressDataObject.alias, + ); + assert(aliasedDefault !== undefined, "default must exist"); - maybe.StressDataObject.globalObjects.default = { type: "newAlias", alias: "default" }; + const maybe: FluidObject | undefined = await aliasedDefault.get(); return maybe; }, }); diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 4c66e83f3e16..136f01d18f47 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -7,21 +7,24 @@ import * as path from "node:path"; import { type AsyncGenerator, - Generator, combineReducersAsync, - createWeightedGenerator, + createWeightedAsyncGenerator, takeAsync, } from "@fluid-private/stochastic-test-utils"; +import type { IFluidHandle } from "@fluidframework/core-interfaces"; +import { assert } from "@fluidframework/core-utils/internal"; +import type { ISharedMap } from "@fluidframework/map/internal"; +import { model as MapFuzzModel } from "@fluidframework/map/internal/test"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { createLocalServerStressSuite, LocalServerStressModel, + makeUnreachableCodePathProxy, type LocalServerStressState, } from "../localServerStressHarness"; import { _dirname } from "./dirname.cjs"; -import { assert } from "@fluidframework/core-utils/internal"; interface UploadBlob { type: "uploadBlob"; @@ -34,7 +37,12 @@ interface CreateDataStore { type: "createDataStore"; } -type StressOperations = UploadBlob | AliasDataStore | CreateDataStore; +interface MapModel { + type: "mapModel"; + op: unknown; +} + +type StressOperations = UploadBlob | AliasDataStore | CreateDataStore | MapModel; const reducer = combineReducersAsync({ aliasDataStore: async (state, op) => { @@ -52,13 +60,41 @@ const reducer = combineReducersAsync({ state.random.string(state.random.integer(1, 246)), ); }, + mapModel: async (state, op) => { + await MapFuzzModel.reducer( + { + clients: makeUnreachableCodePathProxy("clients"), + client: { + channel: state.client.entryPoint.channels.root() as ISharedMap, + containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), + dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), + }, + containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), + isDetached: state.isDetached, + summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), + random: { + ...state.random, + handle: () => { + throw new Error("foo"); + }, + }, + }, + op.op as any, + ); + }, }); function makeGenerator(): AsyncGenerator { - const aliasDataStore: Generator = (state) => { + const aliasDataStore: AsyncGenerator = async ( + state, + ) => { const newDataStores = Object.entries(state.client.entryPoint.globalObjects).filter( - (e): e is [string, { type: "newDatastore"; dataStore: IDataStore }] => - e[1].type === "newDatastore", + ( + e, + ): e is [ + string, + { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle }, + ] => e[1].type === "newDatastore", ); const [id] = state.random.pick(newDataStores); return { @@ -67,21 +103,55 @@ function makeGenerator(): AsyncGenerator([ + const mapGenerator = MapFuzzModel.generatorFactory(); + const mapModel: AsyncGenerator = async (state) => { + const op = await mapGenerator({ + clients: makeUnreachableCodePathProxy("clients"), + client: { + channel: state.client.entryPoint.channels.root() as ISharedMap, + containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), + dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), + }, + containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), + isDetached: state.isDetached, + summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), + random: { + ...state.random, + handle: () => { + return state.random.pick( + Object.values(state.client.entryPoint.globalObjects) + .map((v) => v.handle) + .filter((v): v is IFluidHandle => v !== undefined), + ); + }, + }, + }); + return { + type: "mapModel", + op, + } satisfies MapModel; + }; + + const syncGenerator = createWeightedAsyncGenerator( [ - aliasDataStore, - 1, - (state) => - Object.values(state.client.entryPoint.globalObjects).some( - (v) => v.type === "newDatastore", - ), + [ + aliasDataStore, + 1, + (state) => + Object.values(state.client.entryPoint.globalObjects).some( + (v) => v.type === "newDatastore", + ), + ], + [{ type: "createDataStore" }, 1], + [{ type: "uploadBlob" }, 1], + [mapModel, 10], ], - [{ type: "createDataStore" }, 1], - [{ type: "uploadBlob" }, 1], - ]); + ); return async (state) => syncGenerator(state); } +export const saveFailures = { directory: path.join(_dirname, "../../results") }; +export const saveSuccesses = { directory: path.join(_dirname, "../../results") }; describe("Local Server Stress", () => { const model: LocalServerStressModel = { @@ -101,7 +171,7 @@ describe("Local Server Stress", () => { reconnectProbability: 0, // Uncomment to replay a particular seed. // replay: 0, - saveFailures: { directory: path.join(_dirname, "../../results") }, - saveSuccesses: { directory: path.join(_dirname, "../../results") }, + // saveFailures, + // saveSuccesses, }); }); From 81d8e3a5a3027718cd01b87d2b33ad6d8f0ce427 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 29 Jan 2025 17:09:58 -0800 Subject: [PATCH 11/54] working with some hacks --- .../dds/map/src/test/mocha/map.fuzz.spec.ts | 2 +- .../src/localServerStressHarness.ts | 26 ++++++++------ .../src/test/localServerStress.spec.ts | 34 ++++++++++++++----- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts index 0ab5c6a24f94..0c7db6506dcf 100644 --- a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts +++ b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts @@ -136,7 +136,7 @@ function makeGenerator( export const model: DDSFuzzModel = { workloadName: "default", factory: new MapFactory(), - generatorFactory: () => takeAsync(100, makeGenerator()), + generatorFactory: () => takeAsync(1000, makeGenerator()), reducer: async (state, operation) => reducer(state, operation), validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), }; diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index cd65f5daa34b..62e25d88378a 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -893,11 +893,13 @@ export class StressDataObject extends DataObject { this._globalObjects = root._globalObjects; - this._globalObjects[this.id] = { - type: "stressDataObject", - StressDataObject: this, - handle: this.handle, - }; + setTimeout(() => { + this._globalObjects[this.id] = { + type: "stressDataObject", + StressDataObject: this, + handle: this.handle, + }; + }, 0); } public uploadBlob(id: string, contents: string) { @@ -936,7 +938,11 @@ class DefaultStressDataObject extends StressDataObject { } protected async preInitialize(): Promise { - await super.preInitialize(); + this._globalObjects[this.id] = { + type: "stressDataObject", + StressDataObject: this, + handle: this.handle, + }; this._globalObjects.default = { type: "newAlias", alias: DefaultStressDataObject.alias }; } } @@ -1003,16 +1009,16 @@ async function runTestForSeed( localDeltaConnectionServer, codeLoader, codeDetails, - startDetached ? makeFriendlyClientId(random, 0) : "summarizer", + startDetached ? makeFriendlyClientId(random, 0) : "original", ); - const clients: Client[] = []; + const clients: Client[] = [initialClient]; if (!startDetached) { await initialClient.container.attach(createLocalResolverCreateNewRequest("stress")); const url = await initialClient.container.getAbsoluteUrl(""); assert(url !== undefined, "attached container must have url"); clients.push( ...(await Promise.all( - Array.from({ length: options.numberOfClients }, async (_, i) => + Array.from({ length: options.numberOfClients - 1 }, async (_, i) => loadClient( localDeltaConnectionServer, codeLoader, @@ -1022,8 +1028,6 @@ async function runTestForSeed( ), )), ); - } else { - clients.push(initialClient); } const initialState: LocalServerStressState = { diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 136f01d18f47..ea10b9daa83d 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -9,9 +9,10 @@ import { type AsyncGenerator, combineReducersAsync, createWeightedAsyncGenerator, + done, takeAsync, } from "@fluid-private/stochastic-test-utils"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; +import { fluidHandleSymbol, type IFluidHandle } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; import type { ISharedMap } from "@fluidframework/map/internal"; import { model as MapFuzzModel } from "@fluidframework/map/internal/test"; @@ -103,7 +104,7 @@ function makeGenerator(): AsyncGenerator = async (state) => { const op = await mapGenerator({ clients: makeUnreachableCodePathProxy("clients"), @@ -118,14 +119,31 @@ function makeGenerator(): AsyncGenerator { - return state.random.pick( + const realHandle = state.random.pick( Object.values(state.client.entryPoint.globalObjects) .map((v) => v.handle) .filter((v): v is IFluidHandle => v !== undefined), ); + return { + get [fluidHandleSymbol]() { + return realHandle[fluidHandleSymbol]; + }, + async get() { + return realHandle.get(); + }, + get isAttached() { + return realHandle.isAttached; + }, + }; }, }, }); + + if (op === done) { + mapGenerator = MapFuzzModel.generatorFactory(); + return mapModel(state); + } + return { type: "mapModel", op, @@ -144,7 +162,7 @@ function makeGenerator(): AsyncGenerator { const model: LocalServerStressModel = { workloadName: "default", - generatorFactory: () => takeAsync(100, makeGenerator()), + generatorFactory: () => takeAsync(500, makeGenerator()), reducer: async (state, operation) => reducer(state, operation), validateConsistency: () => {}, }; @@ -169,9 +187,9 @@ describe("Local Server Stress", () => { clientAddProbability: 0.1, }, reconnectProbability: 0, + skipMinimization: true, // Uncomment to replay a particular seed. - // replay: 0, - // saveFailures, - // saveSuccesses, + saveFailures, + saveSuccesses, }); }); From 203f02b34204792775d5a474c778aa78d6496369 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 08:25:12 -0800 Subject: [PATCH 12/54] add reconnect --- .../src/localServerStressHarness.ts | 74 ++++++++++++++++++- .../src/test/localServerStress.spec.ts | 6 +- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 62e25d88378a..0bb0f63f015f 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -1251,13 +1251,83 @@ export function createLocalServerStressSuite( }); } +/** + * @internal + */ +export interface ChangeConnectionState { + type: "changeConnectionState"; + connected: boolean; +} + +/** + * Mixes in functionality to disconnect and reconnect clients in a DDS fuzz model. + * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to + * expose at the package level if we want to expose some of the harness's building blocks. + */ +export function mixinReconnect< + TOperation extends BaseOperation, + TState extends LocalServerStressState, +>( + model: LocalServerStressModel, + options: LocalServerStressOptions, +): LocalServerStressModel { + const generatorFactory: () => AsyncGenerator = + () => { + const baseGenerator = model.generatorFactory(); + return async (state): Promise => { + const baseOp = baseGenerator(state); + if (!state.isDetached && state.random.bool(options.reconnectProbability)) { + const client = state.clients.find((c) => c.id === state.client.id); + assert(client !== undefined); + return { + type: "changeConnectionState", + connected: client.container.connectionState === ConnectionState.Connected, + }; + } + + return baseOp; + }; + }; + + const minimizationTransforms = model.minimizationTransforms as + | MinimizationTransform[] + | undefined; + + const reducer: AsyncReducer = async ( + state, + operation, + ) => { + if (isOperationType("changeConnectionState", operation)) { + if (operation.connected) { + state.client.container.disconnect(); + } else { + state.client.container.connect(); + } + return state; + } else { + return model.reducer(state, operation); + } + }; + return { + ...model, + minimizationTransforms, + generatorFactory, + reducer, + }; +} + const getFullModel = ( ddsModel: LocalServerStressModel, options: LocalServerStressOptions, -): LocalServerStressModel => +): LocalServerStressModel< + TOperation | AddClient | Attach | Synchronize | ChangeConnectionState +> => mixinAttach( mixinSynchronization( - mixinNewClient(mixinClientSelection(ddsModel, options), options), + mixinNewClient( + mixinClientSelection(mixinReconnect(ddsModel, options), options), + options, + ), options, ), options, diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index ea10b9daa83d..3050e5904637 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -50,7 +50,7 @@ const reducer = combineReducersAsync({ const entry = state.client.entryPoint.globalObjects[op.id]; assert(entry.type === "newDatastore", "must be a new datastore"); - await entry.dataStore.trySetAlias(String.fromCodePoint(state.random.integer(0, 26) + 65)); + void entry.dataStore.trySetAlias(String.fromCodePoint(state.random.integer(0, 26) + 65)); }, createDataStore: async (state) => { state.client.entryPoint.createDataStore(state.random.uuid4()); @@ -174,7 +174,7 @@ export const saveSuccesses = { directory: path.join(_dirname, "../../results") } describe("Local Server Stress", () => { const model: LocalServerStressModel = { workloadName: "default", - generatorFactory: () => takeAsync(500, makeGenerator()), + generatorFactory: () => takeAsync(100, makeGenerator()), reducer: async (state, operation) => reducer(state, operation), validateConsistency: () => {}, }; @@ -186,7 +186,7 @@ describe("Local Server Stress", () => { maxNumberOfClients: 6, clientAddProbability: 0.1, }, - reconnectProbability: 0, + reconnectProbability: 0.1, skipMinimization: true, // Uncomment to replay a particular seed. saveFailures, From 1b914deec8f536189a1987aa0f6cf48c19bf7f52 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 08:54:34 -0800 Subject: [PATCH 13/54] add validate model consistency --- .../local-server-stress-tests/package.json | 1 + .../src/localServerStressHarness.ts | 15 ++++-- .../src/test/localServerStress.spec.ts | 50 ++++++++++++++++++- pnpm-lock.yaml | 19 +++---- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/packages/test/local-server-stress-tests/package.json b/packages/test/local-server-stress-tests/package.json index 60bea661b2fa..88684fdb8839 100644 --- a/packages/test/local-server-stress-tests/package.json +++ b/packages/test/local-server-stress-tests/package.json @@ -84,6 +84,7 @@ "@fluidframework/sequence": "workspace:~", "@fluidframework/telemetry-utils": "workspace:~", "@fluidframework/tree": "workspace:~", + "@fluid-private/test-dds-utils": "workspace:~", "uuid": "^9.0.0" }, "devDependencies": { diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 0bb0f63f015f..fabaaa924717 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -31,11 +31,12 @@ import { takeAsync, } from "@fluid-private/stochastic-test-utils"; import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal"; -import type { - ICodeDetailsLoader, - IContainer, - IFluidCodeDetails, - IRuntimeFactory, +import { + AttachState, + type ICodeDetailsLoader, + type IContainer, + type IFluidCodeDetails, + type IRuntimeFactory, } from "@fluidframework/container-definitions/internal"; import { ConnectionState, @@ -884,6 +885,10 @@ export class StressDataObject extends DataObject { return maybe.StressDataObject; } + public get attached() { + return this.runtime.attachState === AttachState.Attached; + } + public channels: Record IChannel> = { root: () => this.root, }; diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 3050e5904637..e7269db922ea 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -12,13 +12,17 @@ import { done, takeAsync, } from "@fluid-private/stochastic-test-utils"; +import { DDSFuzzModel } from "@fluid-private/test-dds-utils"; import { fluidHandleSymbol, type IFluidHandle } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; +import type { IChannel } from "@fluidframework/datastore-definitions/internal"; +import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; import type { ISharedMap } from "@fluidframework/map/internal"; import { model as MapFuzzModel } from "@fluidframework/map/internal/test"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { + Client, createLocalServerStressSuite, LocalServerStressModel, makeUnreachableCodePathProxy, @@ -171,12 +175,56 @@ function makeGenerator(): AsyncGenerator>(); +ddsModelMap.set(MapFuzzModel.factory.type, MapFuzzModel); + +const validateConsistency = async (clientA: Client, clientB: Client) => { + const buildChannelMap = (client: Client) => { + const channelMap = new Map(); + for (const value of Object.values(client.entryPoint.globalObjects).map((v) => + v.type === "stressDataObject" ? v : undefined, + )) { + if (value?.StressDataObject.attached) { + for (const channelF of Object.values(value.StressDataObject.channels)) { + const channel = channelF(); + if (channel.isAttached()) { + channelMap.set(`${value.StressDataObject.id}/${channel.id}`, channel); + } + } + } + } + return channelMap; + }; + const aMap = buildChannelMap(clientA); + const bMap = buildChannelMap(clientB); + assert(aMap.size === bMap.size, "channel maps should be the same size"); + for (const key of aMap.keys()) { + const aChannel = aMap.get(key); + const bChannel = bMap.get(key); + assert(aChannel !== undefined, "types must match"); + assert(aChannel.attributes.type === bChannel?.attributes.type, "types must match"); + const model = ddsModelMap.get(aChannel.attributes.type); + await model?.validateConsistency( + { + channel: aChannel, + containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), + dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), + }, + { + channel: bChannel, + containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), + dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), + }, + ); + } +}; + describe("Local Server Stress", () => { const model: LocalServerStressModel = { workloadName: "default", generatorFactory: () => takeAsync(100, makeGenerator()), reducer: async (state, operation) => reducer(state, operation), - validateConsistency: () => {}, + validateConsistency, }; createLocalServerStressSuite(model, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d401419a8eba..a90746f4be15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13731,10 +13731,7 @@ importers: version: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.97.1) packages/test/local-server-stress-tests: - devDependencies: - '@biomejs/biome': - specifier: ~1.9.3 - version: 1.9.4 + dependencies: '@fluid-experimental/tree': specifier: workspace:~ version: link:../../../experimental/dds/tree @@ -13747,9 +13744,9 @@ importers: '@fluid-private/stochastic-test-utils': specifier: workspace:~ version: link:../stochastic-test-utils - '@fluid-private/test-drivers': + '@fluid-private/test-dds-utils': specifier: workspace:~ - version: link:../test-drivers + version: link:../../dds/test-dds-utils '@fluidframework/aqueduct': specifier: workspace:~ version: link:../../framework/aqueduct @@ -13822,6 +13819,13 @@ importers: '@fluidframework/tree': specifier: workspace:~ version: link:../../dds/tree + uuid: + specifier: ^9.0.0 + version: 9.0.1 + devDependencies: + '@biomejs/biome': + specifier: ~1.9.3 + version: 1.9.4 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -13858,9 +13862,6 @@ importers: typescript: specifier: ~5.4.5 version: 5.4.5 - uuid: - specifier: ^9.0.0 - version: 9.0.1 packages/test/local-server-tests: devDependencies: From 3835077111378245a18c9a4653d3f37651d8d724 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 10:50:48 -0800 Subject: [PATCH 14/54] rename map types --- .../dds/map/src/test/mocha/map.fuzz.spec.ts | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts index 0c7db6506dcf..e392c1ee7ae2 100644 --- a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts +++ b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts @@ -28,25 +28,25 @@ import { type ISharedMap, MapFactory } from "../../index.js"; import { _dirname } from "./dirname.cjs"; -interface Clear { +interface MapClear { type: "clear"; } -interface SetKey { +interface MapSetKey { type: "setKey"; key: string; value: Serializable; } -interface DeleteKey { +interface MapDeleteKey { type: "deleteKey"; key: string; } -type Operation = SetKey | DeleteKey | Clear; +type MapOperation = MapSetKey | MapDeleteKey | MapClear; // This type gets used a lot as the state object of the suite; shorthand it here. -type State = DDSFuzzTestState; +type MapState = DDSFuzzTestState; async function assertMapsAreEquivalent(a: ISharedMap, b: ISharedMap): Promise { assert.equal(a.size, b.size, `${a.id} and ${b.id} have different number of keys.`); @@ -73,7 +73,7 @@ async function assertMapsAreEquivalent(a: ISharedMap, b: ISharedMap): Promise({ +const mapReducer = combineReducers({ clear: ({ client }) => client.channel.clear(), setKey: ({ client }, { key, value }) => { client.channel.set(key, value); @@ -83,31 +83,31 @@ const reducer = combineReducers({ }, }); -interface GeneratorOptions { +interface MapGeneratorOptions { setWeight: number; deleteWeight: number; clearWeight: number; keyPoolSize: number; } -const defaultOptions: GeneratorOptions = { +const mapDefaultOptions: MapGeneratorOptions = { setWeight: 20, deleteWeight: 20, clearWeight: 1, keyPoolSize: 20, }; -function makeGenerator( - optionsParam?: Partial, -): AsyncGenerator { +function mapMakeGenerator( + optionsParam?: Partial, +): AsyncGenerator { const { setWeight, deleteWeight, clearWeight, keyPoolSize } = { - ...defaultOptions, + ...mapDefaultOptions, ...optionsParam, }; // Use numbers as the key names. const keyNames = Array.from({ length: keyPoolSize }, (_, i) => `${i}`); - const setKey: Generator = ({ random }) => ({ + const setKey: Generator = ({ random }) => ({ type: "setKey", key: random.pick(keyNames), value: random.pick([ @@ -116,12 +116,12 @@ function makeGenerator( (): IFluidHandle => random.handle(), ])(), }); - const deleteKey: Generator = ({ random }) => ({ + const deleteKey: Generator = ({ random }) => ({ type: "deleteKey", key: random.pick(keyNames), }); - const syncGenerator = createWeightedGenerator([ + const syncGenerator = createWeightedGenerator([ [setKey, setWeight], [deleteKey, deleteWeight], [{ type: "clear" }, clearWeight], @@ -133,16 +133,16 @@ function makeGenerator( /** * the maps fuzz model */ -export const model: DDSFuzzModel = { +export const mapBaseModel: DDSFuzzModel = { workloadName: "default", factory: new MapFactory(), - generatorFactory: () => takeAsync(1000, makeGenerator()), - reducer: async (state, operation) => reducer(state, operation), + generatorFactory: () => takeAsync(1000, mapMakeGenerator()), + reducer: async (state, operation) => mapReducer(state, operation), validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), }; describe.skip("Map fuzz tests", () => { - createDDSFuzzSuite(model, { + createDDSFuzzSuite(mapBaseModel, { defaultTestCount: 100, numberOfClients: 3, clientJoinOptions: { @@ -157,7 +157,7 @@ describe.skip("Map fuzz tests", () => { }); createDDSFuzzSuite( - { ...model, workloadName: "with reconnect" }, + { ...mapBaseModel, workloadName: "with reconnect" }, { defaultTestCount: 100, numberOfClients: 3, @@ -176,7 +176,7 @@ describe.skip("Map fuzz tests", () => { ); createDDSFuzzSuite( - { ...model, workloadName: "with batches and rebasing" }, + { ...mapBaseModel, workloadName: "with batches and rebasing" }, { defaultTestCount: 100, numberOfClients: 3, From dd394f84f66b7a9d5e3e60d0544e0b66d47d2cc7 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 10:52:15 -0800 Subject: [PATCH 15/54] move map model to utils --- packages/dds/map/src/test/mocha/fuzzUtils.ts | 138 ++++++++++++++++++ .../dds/map/src/test/mocha/map.fuzz.spec.ts | 131 +---------------- 2 files changed, 140 insertions(+), 129 deletions(-) create mode 100644 packages/dds/map/src/test/mocha/fuzzUtils.ts diff --git a/packages/dds/map/src/test/mocha/fuzzUtils.ts b/packages/dds/map/src/test/mocha/fuzzUtils.ts new file mode 100644 index 000000000000..9033ba57d072 --- /dev/null +++ b/packages/dds/map/src/test/mocha/fuzzUtils.ts @@ -0,0 +1,138 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { + type AsyncGenerator, + type Generator, + combineReducers, + createWeightedGenerator, + takeAsync, +} from "@fluid-private/stochastic-test-utils"; +import type { + DDSFuzzModel, + DDSFuzzTestState, +} from "@fluid-private/test-dds-utils"; +import type { IFluidHandle } from "@fluidframework/core-interfaces"; +import { isObject } from "@fluidframework/core-utils/internal"; +import type { Serializable } from "@fluidframework/datastore-definitions/internal"; +import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; + +import { type ISharedMap, MapFactory } from "../../index.js"; + + +interface MapClear { + type: "clear"; +} + +interface MapSetKey { + type: "setKey"; + key: string; + value: Serializable; +} + +interface MapDeleteKey { + type: "deleteKey"; + key: string; +} + +type MapOperation = MapSetKey | MapDeleteKey | MapClear; + +// This type gets used a lot as the state object of the suite; shorthand it here. +type MapState = DDSFuzzTestState; + +async function assertMapsAreEquivalent(a: ISharedMap, b: ISharedMap): Promise { + assert.equal(a.size, b.size, `${a.id} and ${b.id} have different number of keys.`); + for (const key of a.keys()) { + const aVal: unknown = a.get(key); + const bVal: unknown = b.get(key); + if (isObject(aVal) === true) { + assert( + isObject(bVal), + `${a.id} and ${b.id} differ at ${key}: a is an object, b is not}`, + ); + const aHandle = isFluidHandle(aVal) ? await aVal.get() : aVal; + const bHandle = isFluidHandle(bVal) ? await bVal.get() : bVal; + assert.equal( + aHandle, + bHandle, + `${a.id} and ${b.id} differ at ${key}: ${JSON.stringify(aHandle)} vs ${JSON.stringify( + bHandle, + )}`, + ); + } else { + assert.equal(aVal, bVal, `${a.id} and ${b.id} differ at ${key}: ${aVal} vs ${bVal}`); + } + } +} + +const mapReducer = combineReducers({ + clear: ({ client }) => client.channel.clear(), + setKey: ({ client }, { key, value }) => { + client.channel.set(key, value); + }, + deleteKey: ({ client }, { key }) => { + client.channel.delete(key); + }, +}); + +interface MapGeneratorOptions { + setWeight: number; + deleteWeight: number; + clearWeight: number; + keyPoolSize: number; +} + +const mapDefaultOptions: MapGeneratorOptions = { + setWeight: 20, + deleteWeight: 20, + clearWeight: 1, + keyPoolSize: 20, +}; + +function mapMakeGenerator( + optionsParam?: Partial, +): AsyncGenerator { + const { setWeight, deleteWeight, clearWeight, keyPoolSize } = { + ...mapDefaultOptions, + ...optionsParam, + }; + // Use numbers as the key names. + const keyNames = Array.from({ length: keyPoolSize }, (_, i) => `${i}`); + + const setKey: Generator = ({ random }) => ({ + type: "setKey", + key: random.pick(keyNames), + value: random.pick([ + (): number => random.integer(1, 50), + (): string => random.string(random.integer(3, 7)), + (): IFluidHandle => random.handle(), + ])(), + }); + const deleteKey: Generator = ({ random }) => ({ + type: "deleteKey", + key: random.pick(keyNames), + }); + + const syncGenerator = createWeightedGenerator([ + [setKey, setWeight], + [deleteKey, deleteWeight], + [{ type: "clear" }, clearWeight], + ]); + + return async (state) => syncGenerator(state); +} + +/** + * the maps fuzz model + */ +export const mapBaseModel: DDSFuzzModel = { + workloadName: "default", + factory: new MapFactory(), + generatorFactory: () => takeAsync(1000, mapMakeGenerator()), + reducer: async (state, operation) => mapReducer(state, operation), + validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), +}; diff --git a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts index e392c1ee7ae2..749b61eb3e56 100644 --- a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts +++ b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts @@ -3,145 +3,18 @@ * Licensed under the MIT License. */ -import { strict as assert } from "node:assert"; import * as path from "node:path"; import { - type AsyncGenerator, - type Generator, - combineReducers, - createWeightedGenerator, - takeAsync, -} from "@fluid-private/stochastic-test-utils"; -import { - type DDSFuzzModel, - type DDSFuzzTestState, createDDSFuzzSuite, } from "@fluid-private/test-dds-utils"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; -import { isObject } from "@fluidframework/core-utils/internal"; -import type { Serializable } from "@fluidframework/datastore-definitions/internal"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; -import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; -import { type ISharedMap, MapFactory } from "../../index.js"; import { _dirname } from "./dirname.cjs"; +import { mapBaseModel } from "./fuzzUtils.js"; -interface MapClear { - type: "clear"; -} - -interface MapSetKey { - type: "setKey"; - key: string; - value: Serializable; -} - -interface MapDeleteKey { - type: "deleteKey"; - key: string; -} - -type MapOperation = MapSetKey | MapDeleteKey | MapClear; - -// This type gets used a lot as the state object of the suite; shorthand it here. -type MapState = DDSFuzzTestState; - -async function assertMapsAreEquivalent(a: ISharedMap, b: ISharedMap): Promise { - assert.equal(a.size, b.size, `${a.id} and ${b.id} have different number of keys.`); - for (const key of a.keys()) { - const aVal: unknown = a.get(key); - const bVal: unknown = b.get(key); - if (isObject(aVal) === true) { - assert( - isObject(bVal), - `${a.id} and ${b.id} differ at ${key}: a is an object, b is not}`, - ); - const aHandle = isFluidHandle(aVal) ? await aVal.get() : aVal; - const bHandle = isFluidHandle(bVal) ? await bVal.get() : bVal; - assert.equal( - aHandle, - bHandle, - `${a.id} and ${b.id} differ at ${key}: ${JSON.stringify(aHandle)} vs ${JSON.stringify( - bHandle, - )}`, - ); - } else { - assert.equal(aVal, bVal, `${a.id} and ${b.id} differ at ${key}: ${aVal} vs ${bVal}`); - } - } -} - -const mapReducer = combineReducers({ - clear: ({ client }) => client.channel.clear(), - setKey: ({ client }, { key, value }) => { - client.channel.set(key, value); - }, - deleteKey: ({ client }, { key }) => { - client.channel.delete(key); - }, -}); - -interface MapGeneratorOptions { - setWeight: number; - deleteWeight: number; - clearWeight: number; - keyPoolSize: number; -} - -const mapDefaultOptions: MapGeneratorOptions = { - setWeight: 20, - deleteWeight: 20, - clearWeight: 1, - keyPoolSize: 20, -}; - -function mapMakeGenerator( - optionsParam?: Partial, -): AsyncGenerator { - const { setWeight, deleteWeight, clearWeight, keyPoolSize } = { - ...mapDefaultOptions, - ...optionsParam, - }; - // Use numbers as the key names. - const keyNames = Array.from({ length: keyPoolSize }, (_, i) => `${i}`); - - const setKey: Generator = ({ random }) => ({ - type: "setKey", - key: random.pick(keyNames), - value: random.pick([ - (): number => random.integer(1, 50), - (): string => random.string(random.integer(3, 7)), - (): IFluidHandle => random.handle(), - ])(), - }); - const deleteKey: Generator = ({ random }) => ({ - type: "deleteKey", - key: random.pick(keyNames), - }); - - const syncGenerator = createWeightedGenerator([ - [setKey, setWeight], - [deleteKey, deleteWeight], - [{ type: "clear" }, clearWeight], - ]); - - return async (state) => syncGenerator(state); -} - -/** - * the maps fuzz model - */ -export const mapBaseModel: DDSFuzzModel = { - workloadName: "default", - factory: new MapFactory(), - generatorFactory: () => takeAsync(1000, mapMakeGenerator()), - reducer: async (state, operation) => mapReducer(state, operation), - validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), -}; - -describe.skip("Map fuzz tests", () => { +describe("Map fuzz tests", () => { createDDSFuzzSuite(mapBaseModel, { defaultTestCount: 100, numberOfClients: 3, From f369dfa2e68ef2ed4df81eea0d3393fff51ce3b2 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 10:55:31 -0800 Subject: [PATCH 16/54] rename dir fuzz types --- .../src/test/mocha/directoryFuzzTests.spec.ts | 105 +++++++++--------- 1 file changed, 54 insertions(+), 51 deletions(-) diff --git a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts index e9d632c2740a..825b01df5d1c 100644 --- a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts +++ b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts @@ -28,21 +28,21 @@ import { DirectoryFactory, type IDirectory } from "../../index.js"; import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; import { _dirname } from "./dirname.cjs"; -type FuzzTestState = DDSFuzzTestState; +type DirFuzzTestState = DDSFuzzTestState; -interface SetKey { +interface DirSetKey { type: "set"; path: string; key: string; value: Serializable; } -interface ClearKeys { +interface DirClearKeys { type: "clear"; path: string; } -interface DeleteKey { +interface DirDeleteKey { type: "delete"; path: string; key: string; @@ -60,13 +60,13 @@ interface DeleteSubDirectory { name: string; } -type KeyOperation = SetKey | DeleteKey | ClearKeys; +type DirKeyOperation = DirSetKey | DirDeleteKey | DirClearKeys; type SubDirectoryOperation = CreateSubDirectory | DeleteSubDirectory; -type Operation = KeyOperation | SubDirectoryOperation; +type DirOperation = DirKeyOperation | SubDirectoryOperation; -interface OperationGenerationConfig { +interface DirOperationGenerationConfig { validateInterval: number; maxSubDirectoryChild?: number; subDirectoryNamePool?: string[]; @@ -78,7 +78,7 @@ interface OperationGenerationConfig { deleteSubDirWeight?: number; } -const defaultOptions: Required = { +const dirDefaultOptions: Required = { validateInterval: 10, maxSubDirectoryChild: 3, subDirectoryNamePool: ["dir1", "dir2", "dir3"], @@ -90,7 +90,7 @@ const defaultOptions: Required = { deleteSubDirWeight: 1, }; -function pickAbsolutePathForKeyOps(state: FuzzTestState, shouldHaveKey: boolean): string { +function pickAbsolutePathForKeyOps(state: DirFuzzTestState, shouldHaveKey: boolean): string { const { random, client } = state; let parentDir: IDirectory = client.channel; for (;;) { @@ -109,13 +109,13 @@ function pickAbsolutePathForKeyOps(state: FuzzTestState, shouldHaveKey: boolean) return parentDir.absolutePath; } -function makeOperationGenerator( - optionsParam?: OperationGenerationConfig, -): AsyncGenerator { - const options = { ...defaultOptions, ...optionsParam }; +function makeDirOperationGenerator( + optionsParam?: DirOperationGenerationConfig, +): AsyncGenerator { + const options = { ...dirDefaultOptions, ...optionsParam }; // All subsequent helper functions are generators; note that they don't actually apply any operations. - function pickAbsolutePathForCreateDirectoryOp(state: FuzzTestState): string { + function pickAbsolutePathForCreateDirectoryOp(state: DirFuzzTestState): string { const { random, client } = state; let dir: IDirectory = client.channel; for (;;) { @@ -142,7 +142,7 @@ function makeOperationGenerator( return dir.absolutePath; } - function pickAbsolutePathForDeleteDirectoryOp(state: FuzzTestState): string { + function pickAbsolutePathForDeleteDirectoryOp(state: DirFuzzTestState): string { const { random, client } = state; let parentDir: IDirectory = client.channel; const subDirectories: IDirectory[] = []; @@ -167,7 +167,7 @@ function makeOperationGenerator( return parentDir.absolutePath; } - async function createSubDirectory(state: FuzzTestState): Promise { + async function createSubDirectory(state: DirFuzzTestState): Promise { return { type: "createSubDirectory", name: state.random.pick(options.subDirectoryNamePool), @@ -175,7 +175,7 @@ function makeOperationGenerator( }; } - async function deleteSubDirectory(state: FuzzTestState): Promise { + async function deleteSubDirectory(state: DirFuzzTestState): Promise { const { random, client } = state; const path = pickAbsolutePathForDeleteDirectoryOp(state); const parentDir = client.channel.getWorkingDirectory(path); @@ -195,7 +195,7 @@ function makeOperationGenerator( }; } - async function setKey(state: FuzzTestState): Promise { + async function setKey(state: DirFuzzTestState): Promise { const { random } = state; return { type: "set", @@ -208,14 +208,14 @@ function makeOperationGenerator( }; } - async function clearKeys(state: FuzzTestState): Promise { + async function clearKeys(state: DirFuzzTestState): Promise { return { type: "clear", path: pickAbsolutePathForKeyOps(state, true), }; } - async function deleteKey(state: FuzzTestState): Promise { + async function deleteKey(state: DirFuzzTestState): Promise { const { random, client } = state; const path = pickAbsolutePathForKeyOps(state, true); const dir = client.channel.getWorkingDirectory(path); @@ -227,23 +227,23 @@ function makeOperationGenerator( }; } - return createWeightedAsyncGenerator([ + return createWeightedAsyncGenerator([ [createSubDirectory, options.createSubDirWeight], [ deleteSubDirectory, options.deleteSubDirWeight, - (state: FuzzTestState): boolean => (state.client.channel.countSubDirectory?.() ?? 0) > 0, + (state: DirFuzzTestState): boolean => (state.client.channel.countSubDirectory?.() ?? 0) > 0, ], [setKey, options.setKeyWeight], [ deleteKey, options.deleteKeyWeight, - (state: FuzzTestState): boolean => state.client.channel.size > 0, + (state: DirFuzzTestState): boolean => state.client.channel.size > 0, ], [ clearKeys, options.clearKeysWeight, - (state: FuzzTestState): boolean => state.client.channel.size > 0, + (state: DirFuzzTestState): boolean => state.client.channel.size > 0, ], ]); } @@ -269,9 +269,9 @@ function logCurrentState(clients: Client[], loggingInfo: Loggi } } -function makeReducer(loggingInfo?: LoggingInfo): AsyncReducer { +function makeDirReducer(loggingInfo?: LoggingInfo): AsyncReducer { const withLogging = - (baseReducer: AsyncReducer): AsyncReducer => + (baseReducer: AsyncReducer): AsyncReducer => async (state, operation) => { if (loggingInfo !== undefined && loggingInfo.printConsoleLogs) { logCurrentState(state.clients, loggingInfo); @@ -289,7 +289,7 @@ function makeReducer(loggingInfo?: LoggingInfo): AsyncReducer = combineReducersAsync({ + const reducer: AsyncReducer = combineReducersAsync({ createSubDirectory: async ({ client }, { path, name }) => { const dir = client.channel.getWorkingDirectory(path); assert(dir); @@ -320,27 +320,30 @@ function makeReducer(loggingInfo?: LoggingInfo): AsyncReducer = { + workloadName: "default directory 1", + generatorFactory: () => takeAsync(100, makeDirOperationGenerator(dirOptions)), + reducer: makeDirReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), + validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), + factory: new DirectoryFactory(), +}; + describe("SharedDirectory fuzz Create/Delete concentrated", () => { - const options: OperationGenerationConfig = { - setKeyWeight: 0, - clearKeysWeight: 0, - deleteKeyWeight: 0, - createSubDirWeight: 2, - deleteSubDirWeight: 2, - maxSubDirectoryChild: 2, - subDirectoryNamePool: ["dir1", "dir2"], - validateInterval: defaultOptions.validateInterval, - }; - const model: DDSFuzzModel = { - workloadName: "default directory 1", - generatorFactory: () => takeAsync(100, makeOperationGenerator(options)), - reducer: makeReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), - validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), - factory: new DirectoryFactory(), - }; - createDDSFuzzSuite(model, { - validationStrategy: { type: "fixedInterval", interval: defaultOptions.validateInterval }, + createDDSFuzzSuite(baseDirModel, { + validationStrategy: { type: "fixedInterval", interval: dirDefaultOptions.validateInterval }, reconnectProbability: 0.15, numberOfClients: 3, // We prevent handles from being generated on the creation/deletion tests since the set operations are disabled. @@ -357,7 +360,7 @@ describe("SharedDirectory fuzz Create/Delete concentrated", () => { }); createDDSFuzzSuite( - { ...model, workloadName: "default directory 1 with rebasing" }, + { ...baseDirModel, workloadName: "default directory 1 with rebasing" }, { validationStrategy: { type: "random", @@ -388,16 +391,16 @@ describe("SharedDirectory fuzz Create/Delete concentrated", () => { }); describe("SharedDirectory fuzz", () => { - const model: DDSFuzzModel = { + const model: DDSFuzzModel = { workloadName: "default directory 2", - generatorFactory: () => takeAsync(100, makeOperationGenerator()), - reducer: makeReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), + generatorFactory: () => takeAsync(100, makeDirOperationGenerator()), + reducer: makeDirReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), factory: new DirectoryFactory(), }; createDDSFuzzSuite(model, { - validationStrategy: { type: "fixedInterval", interval: defaultOptions.validateInterval }, + validationStrategy: { type: "fixedInterval", interval: dirDefaultOptions.validateInterval }, reconnectProbability: 0.15, numberOfClients: 3, clientJoinOptions: { From 6610ad2f13b7aff44873f96dafa91152a061dc75 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 11:04:45 -0800 Subject: [PATCH 17/54] export map and dir models --- packages/dds/map/src/test/index.ts | 2 +- .../src/test/mocha/directoryFuzzTests.spec.ts | 324 +--------------- packages/dds/map/src/test/mocha/fuzzUtils.ts | 362 +++++++++++++++++- packages/dds/map/src/test/mocha/index.ts | 2 +- .../dds/map/src/test/mocha/map.fuzz.spec.ts | 8 +- 5 files changed, 368 insertions(+), 330 deletions(-) diff --git a/packages/dds/map/src/test/index.ts b/packages/dds/map/src/test/index.ts index 098e8522c2b1..b734a136483f 100644 --- a/packages/dds/map/src/test/index.ts +++ b/packages/dds/map/src/test/index.ts @@ -2,4 +2,4 @@ * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. */ -export { model } from "./mocha/index.js"; +export { baseDirModel, baseMapModel } from "./mocha/index.js"; diff --git a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts index 825b01df5d1c..681c641d3c4f 100644 --- a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts +++ b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts @@ -3,342 +3,22 @@ * Licensed under the MIT License. */ -import { strict as assert } from "node:assert"; import * as dirPath from "node:path"; import { - type AsyncGenerator, - type AsyncReducer, - combineReducersAsync, - createWeightedAsyncGenerator, takeAsync, } from "@fluid-private/stochastic-test-utils"; import { - type Client, type DDSFuzzModel, - type DDSFuzzTestState, createDDSFuzzSuite, } from "@fluid-private/test-dds-utils"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; -import type { Serializable } from "@fluidframework/datastore-definitions/internal"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; -import { DirectoryFactory, type IDirectory } from "../../index.js"; +import { DirectoryFactory } from "../../index.js"; import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; import { _dirname } from "./dirname.cjs"; - -type DirFuzzTestState = DDSFuzzTestState; - -interface DirSetKey { - type: "set"; - path: string; - key: string; - value: Serializable; -} - -interface DirClearKeys { - type: "clear"; - path: string; -} - -interface DirDeleteKey { - type: "delete"; - path: string; - key: string; -} - -interface CreateSubDirectory { - type: "createSubDirectory"; - path: string; - name: string; -} - -interface DeleteSubDirectory { - type: "deleteSubDirectory"; - path: string; - name: string; -} - -type DirKeyOperation = DirSetKey | DirDeleteKey | DirClearKeys; - -type SubDirectoryOperation = CreateSubDirectory | DeleteSubDirectory; - -type DirOperation = DirKeyOperation | SubDirectoryOperation; - -interface DirOperationGenerationConfig { - validateInterval: number; - maxSubDirectoryChild?: number; - subDirectoryNamePool?: string[]; - keyNamePool?: string[]; - setKeyWeight?: number; - deleteKeyWeight?: number; - clearKeysWeight?: number; - createSubDirWeight?: number; - deleteSubDirWeight?: number; -} - -const dirDefaultOptions: Required = { - validateInterval: 10, - maxSubDirectoryChild: 3, - subDirectoryNamePool: ["dir1", "dir2", "dir3"], - keyNamePool: ["prop1", "prop2", "prop3"], - setKeyWeight: 5, - deleteKeyWeight: 2, - clearKeysWeight: 1, - createSubDirWeight: 2, - deleteSubDirWeight: 1, -}; - -function pickAbsolutePathForKeyOps(state: DirFuzzTestState, shouldHaveKey: boolean): string { - const { random, client } = state; - let parentDir: IDirectory = client.channel; - for (;;) { - assert(parentDir !== undefined, "Directory should be defined"); - const subDirs: IDirectory[] = []; - for (const [_, b] of parentDir.subdirectories()) { - subDirs.push(b); - } - const subDir = random.pick([undefined, ...subDirs]); - if (subDir !== undefined && (!shouldHaveKey || subDir.size > 0)) { - parentDir = subDir; - } else { - break; - } - } - return parentDir.absolutePath; -} - -function makeDirOperationGenerator( - optionsParam?: DirOperationGenerationConfig, -): AsyncGenerator { - const options = { ...dirDefaultOptions, ...optionsParam }; - - // All subsequent helper functions are generators; note that they don't actually apply any operations. - function pickAbsolutePathForCreateDirectoryOp(state: DirFuzzTestState): string { - const { random, client } = state; - let dir: IDirectory = client.channel; - for (;;) { - assert(dir !== undefined, "Directory should be defined"); - const subDirectories: IDirectory[] = []; - for (const [_, b] of dir.subdirectories()) { - subDirectories.push(b); - } - // If this dir already has max number of child, then choose one and continue. - if ( - dir.countSubDirectory !== undefined && - dir.countSubDirectory() === options.maxSubDirectoryChild - ) { - dir = random.pick(subDirectories); - continue; - } - const subDir = random.pick([undefined, ...subDirectories]); - if (subDir === undefined) { - break; - } else { - dir = subDir; - } - } - return dir.absolutePath; - } - - function pickAbsolutePathForDeleteDirectoryOp(state: DirFuzzTestState): string { - const { random, client } = state; - let parentDir: IDirectory = client.channel; - const subDirectories: IDirectory[] = []; - for (const [_, b] of client.channel.subdirectories()) { - subDirectories.push(b); - } - let dirToDelete = random.pick(subDirectories); - for (;;) { - assert(dirToDelete !== undefined, "Directory should be defined"); - const subDirs: IDirectory[] = []; - for (const [_, b] of dirToDelete.subdirectories()) { - subDirs.push(b); - } - const subDir = random.pick([undefined, ...subDirs]); - if (subDir === undefined) { - break; - } else { - parentDir = dirToDelete; - dirToDelete = subDir; - } - } - return parentDir.absolutePath; - } - - async function createSubDirectory(state: DirFuzzTestState): Promise { - return { - type: "createSubDirectory", - name: state.random.pick(options.subDirectoryNamePool), - path: pickAbsolutePathForCreateDirectoryOp(state), - }; - } - - async function deleteSubDirectory(state: DirFuzzTestState): Promise { - const { random, client } = state; - const path = pickAbsolutePathForDeleteDirectoryOp(state); - const parentDir = client.channel.getWorkingDirectory(path); - assert(parentDir !== undefined, "parent dir should be defined"); - assert( - parentDir.countSubDirectory && parentDir.countSubDirectory() > 0, - "Atleast 1 subdir should be there", - ); - const subDirName: string[] = []; - for (const [a, _] of parentDir.subdirectories()) { - subDirName.push(a); - } - return { - type: "deleteSubDirectory", - name: random.pick(subDirName), - path, - }; - } - - async function setKey(state: DirFuzzTestState): Promise { - const { random } = state; - return { - type: "set", - key: random.pick(options.keyNamePool), - path: pickAbsolutePathForKeyOps(state, false), - value: random.pick([ - (): string => random.string(random.integer(0, 4)), - (): IFluidHandle => random.handle(), - ])(), - }; - } - - async function clearKeys(state: DirFuzzTestState): Promise { - return { - type: "clear", - path: pickAbsolutePathForKeyOps(state, true), - }; - } - - async function deleteKey(state: DirFuzzTestState): Promise { - const { random, client } = state; - const path = pickAbsolutePathForKeyOps(state, true); - const dir = client.channel.getWorkingDirectory(path); - assert(dir, "dir should exist"); - return { - type: "delete", - key: random.pick([...dir.keys()]), - path, - }; - } - - return createWeightedAsyncGenerator([ - [createSubDirectory, options.createSubDirWeight], - [ - deleteSubDirectory, - options.deleteSubDirWeight, - (state: DirFuzzTestState): boolean => (state.client.channel.countSubDirectory?.() ?? 0) > 0, - ], - [setKey, options.setKeyWeight], - [ - deleteKey, - options.deleteKeyWeight, - (state: DirFuzzTestState): boolean => state.client.channel.size > 0, - ], - [ - clearKeys, - options.clearKeysWeight, - (state: DirFuzzTestState): boolean => state.client.channel.size > 0, - ], - ]); -} - -interface LoggingInfo { - // Clients to print - clientIds: string[]; - // Set this to true in case you want to debug and print client states and ops. - printConsoleLogs?: boolean; -} - -function logCurrentState(clients: Client[], loggingInfo: LoggingInfo): void { - for (const id of loggingInfo.clientIds) { - const { channel: sharedDirectory } = - clients.find((s) => s.containerRuntime.clientId === id) ?? {}; - if (sharedDirectory !== undefined) { - console.log(`Client ${id}:`); - console.log( - JSON.stringify(sharedDirectory.getAttachSummary(true).summary, undefined, 4), - ); - console.log("\n"); - } - } -} - -function makeDirReducer(loggingInfo?: LoggingInfo): AsyncReducer { - const withLogging = - (baseReducer: AsyncReducer): AsyncReducer => - async (state, operation) => { - if (loggingInfo !== undefined && loggingInfo.printConsoleLogs) { - logCurrentState(state.clients, loggingInfo); - console.log("-".repeat(20)); - console.log("Next operation:", JSON.stringify(operation, undefined, 4)); - } - try { - await baseReducer(state, operation); - } catch (error) { - if (loggingInfo !== undefined) { - logCurrentState(state.clients, loggingInfo); - } - throw error; - } - return state; - }; - - const reducer: AsyncReducer = combineReducersAsync({ - createSubDirectory: async ({ client }, { path, name }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.createSubDirectory(name); - }, - deleteSubDirectory: async ({ client }, { path, name }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.deleteSubDirectory(name); - }, - set: async ({ client }, { path, key, value }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.set(key, value); - }, - clear: async ({ client }, { path }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.clear(); - }, - delete: async ({ client }, { path, key }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.delete(key); - }, - }); - - return withLogging(reducer); -} - -const dirOptions: DirOperationGenerationConfig = { - setKeyWeight: 0, - clearKeysWeight: 0, - deleteKeyWeight: 0, - createSubDirWeight: 2, - deleteSubDirWeight: 2, - maxSubDirectoryChild: 2, - subDirectoryNamePool: ["dir1", "dir2"], - validateInterval: dirDefaultOptions.validateInterval, -}; - - -const baseDirModel: DDSFuzzModel = { - workloadName: "default directory 1", - generatorFactory: () => takeAsync(100, makeDirOperationGenerator(dirOptions)), - reducer: makeDirReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), - validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), - factory: new DirectoryFactory(), -}; +import { baseDirModel, dirDefaultOptions } from "./fuzzUtils.js"; describe("SharedDirectory fuzz Create/Delete concentrated", () => { diff --git a/packages/dds/map/src/test/mocha/fuzzUtils.ts b/packages/dds/map/src/test/mocha/fuzzUtils.ts index 9033ba57d072..a732e5c58582 100644 --- a/packages/dds/map/src/test/mocha/fuzzUtils.ts +++ b/packages/dds/map/src/test/mocha/fuzzUtils.ts @@ -7,12 +7,16 @@ import { strict as assert } from "node:assert"; import { type AsyncGenerator, + type AsyncReducer, type Generator, combineReducers, + combineReducersAsync, + createWeightedAsyncGenerator, createWeightedGenerator, takeAsync, } from "@fluid-private/stochastic-test-utils"; import type { + Client, DDSFuzzModel, DDSFuzzTestState, } from "@fluid-private/test-dds-utils"; @@ -21,7 +25,9 @@ import { isObject } from "@fluidframework/core-utils/internal"; import type { Serializable } from "@fluidframework/datastore-definitions/internal"; import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; -import { type ISharedMap, MapFactory } from "../../index.js"; +import { DirectoryFactory, type IDirectory, type ISharedMap, MapFactory } from "../../index.js"; + +import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; interface MapClear { @@ -129,10 +135,362 @@ function mapMakeGenerator( /** * the maps fuzz model */ -export const mapBaseModel: DDSFuzzModel = { +export const baseMapModel: DDSFuzzModel = { workloadName: "default", factory: new MapFactory(), generatorFactory: () => takeAsync(1000, mapMakeGenerator()), reducer: async (state, operation) => mapReducer(state, operation), validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), }; + + + +type DirFuzzTestState = DDSFuzzTestState; + +/** + * + */ +export interface DirSetKey { + type: "set"; + path: string; + key: string; + value: Serializable; +} + +/** + * + */ +export interface DirClearKeys { + type: "clear"; + path: string; +} + +/** + * + */ +export interface DirDeleteKey { + type: "delete"; + path: string; + key: string; +} + +/** + * + */ +export interface CreateSubDirectory { + type: "createSubDirectory"; + path: string; + name: string; +} + +/** + * + */ +export interface DeleteSubDirectory { + type: "deleteSubDirectory"; + path: string; + name: string; +} + +/** + * + */ +export type DirKeyOperation = DirSetKey | DirDeleteKey | DirClearKeys; + +/** + * + */ +export type SubDirectoryOperation = CreateSubDirectory | DeleteSubDirectory; + +/** + * + */ +export type DirOperation = DirKeyOperation | SubDirectoryOperation; + +interface DirOperationGenerationConfig { + validateInterval: number; + maxSubDirectoryChild?: number; + subDirectoryNamePool?: string[]; + keyNamePool?: string[]; + setKeyWeight?: number; + deleteKeyWeight?: number; + clearKeysWeight?: number; + createSubDirWeight?: number; + deleteSubDirWeight?: number; +} + +/** + * The default options for the directory fuzz model + */ +export const dirDefaultOptions: Required = { + validateInterval: 10, + maxSubDirectoryChild: 3, + subDirectoryNamePool: ["dir1", "dir2", "dir3"], + keyNamePool: ["prop1", "prop2", "prop3"], + setKeyWeight: 5, + deleteKeyWeight: 2, + clearKeysWeight: 1, + createSubDirWeight: 2, + deleteSubDirWeight: 1, +}; + +function pickAbsolutePathForKeyOps(state: DirFuzzTestState, shouldHaveKey: boolean): string { + const { random, client } = state; + let parentDir: IDirectory = client.channel; + for (;;) { + assert(parentDir !== undefined, "Directory should be defined"); + const subDirs: IDirectory[] = []; + for (const [_, b] of parentDir.subdirectories()) { + subDirs.push(b); + } + const subDir = random.pick([undefined, ...subDirs]); + if (subDir !== undefined && (!shouldHaveKey || subDir.size > 0)) { + parentDir = subDir; + } else { + break; + } + } + return parentDir.absolutePath; +} + +/** + * + */ +export function makeDirOperationGenerator( + optionsParam?: DirOperationGenerationConfig, +): AsyncGenerator { + const options = { ...dirDefaultOptions, ...optionsParam }; + + // All subsequent helper functions are generators; note that they don't actually apply any operations. + function pickAbsolutePathForCreateDirectoryOp(state: DirFuzzTestState): string { + const { random, client } = state; + let dir: IDirectory = client.channel; + for (;;) { + assert(dir !== undefined, "Directory should be defined"); + const subDirectories: IDirectory[] = []; + for (const [_, b] of dir.subdirectories()) { + subDirectories.push(b); + } + // If this dir already has max number of child, then choose one and continue. + if ( + dir.countSubDirectory !== undefined && + dir.countSubDirectory() === options.maxSubDirectoryChild + ) { + dir = random.pick(subDirectories); + continue; + } + const subDir = random.pick([undefined, ...subDirectories]); + if (subDir === undefined) { + break; + } else { + dir = subDir; + } + } + return dir.absolutePath; + } + + function pickAbsolutePathForDeleteDirectoryOp(state: DirFuzzTestState): string { + const { random, client } = state; + let parentDir: IDirectory = client.channel; + const subDirectories: IDirectory[] = []; + for (const [_, b] of client.channel.subdirectories()) { + subDirectories.push(b); + } + let dirToDelete = random.pick(subDirectories); + for (;;) { + assert(dirToDelete !== undefined, "Directory should be defined"); + const subDirs: IDirectory[] = []; + for (const [_, b] of dirToDelete.subdirectories()) { + subDirs.push(b); + } + const subDir = random.pick([undefined, ...subDirs]); + if (subDir === undefined) { + break; + } else { + parentDir = dirToDelete; + dirToDelete = subDir; + } + } + return parentDir.absolutePath; + } + + async function createSubDirectory(state: DirFuzzTestState): Promise { + return { + type: "createSubDirectory", + name: state.random.pick(options.subDirectoryNamePool), + path: pickAbsolutePathForCreateDirectoryOp(state), + }; + } + + async function deleteSubDirectory(state: DirFuzzTestState): Promise { + const { random, client } = state; + const path = pickAbsolutePathForDeleteDirectoryOp(state); + const parentDir = client.channel.getWorkingDirectory(path); + assert(parentDir !== undefined, "parent dir should be defined"); + assert( + parentDir.countSubDirectory && parentDir.countSubDirectory() > 0, + "Atleast 1 subdir should be there", + ); + const subDirName: string[] = []; + for (const [a, _] of parentDir.subdirectories()) { + subDirName.push(a); + } + return { + type: "deleteSubDirectory", + name: random.pick(subDirName), + path, + }; + } + + async function setKey(state: DirFuzzTestState): Promise { + const { random } = state; + return { + type: "set", + key: random.pick(options.keyNamePool), + path: pickAbsolutePathForKeyOps(state, false), + value: random.pick([ + (): string => random.string(random.integer(0, 4)), + (): IFluidHandle => random.handle(), + ])(), + }; + } + + async function clearKeys(state: DirFuzzTestState): Promise { + return { + type: "clear", + path: pickAbsolutePathForKeyOps(state, true), + }; + } + + async function deleteKey(state: DirFuzzTestState): Promise { + const { random, client } = state; + const path = pickAbsolutePathForKeyOps(state, true); + const dir = client.channel.getWorkingDirectory(path); + assert(dir, "dir should exist"); + return { + type: "delete", + key: random.pick([...dir.keys()]), + path, + }; + } + + return createWeightedAsyncGenerator([ + [createSubDirectory, options.createSubDirWeight], + [ + deleteSubDirectory, + options.deleteSubDirWeight, + (state: DirFuzzTestState): boolean => (state.client.channel.countSubDirectory?.() ?? 0) > 0, + ], + [setKey, options.setKeyWeight], + [ + deleteKey, + options.deleteKeyWeight, + (state: DirFuzzTestState): boolean => state.client.channel.size > 0, + ], + [ + clearKeys, + options.clearKeysWeight, + (state: DirFuzzTestState): boolean => state.client.channel.size > 0, + ], + ]); +} + +interface LoggingInfo { + // Clients to print + clientIds: string[]; + // Set this to true in case you want to debug and print client states and ops. + printConsoleLogs?: boolean; +} + +function logCurrentState(clients: Client[], loggingInfo: LoggingInfo): void { + for (const id of loggingInfo.clientIds) { + const { channel: sharedDirectory } = + clients.find((s) => s.containerRuntime.clientId === id) ?? {}; + if (sharedDirectory !== undefined) { + console.log(`Client ${id}:`); + console.log( + JSON.stringify(sharedDirectory.getAttachSummary(true).summary, undefined, 4), + ); + console.log("\n"); + } + } +} + +/** + * + */ +export function makeDirReducer(loggingInfo?: LoggingInfo): AsyncReducer { + const withLogging = + (baseReducer: AsyncReducer): AsyncReducer => + async (state, operation) => { + if (loggingInfo !== undefined && loggingInfo.printConsoleLogs) { + logCurrentState(state.clients, loggingInfo); + console.log("-".repeat(20)); + console.log("Next operation:", JSON.stringify(operation, undefined, 4)); + } + try { + await baseReducer(state, operation); + } catch (error) { + if (loggingInfo !== undefined) { + logCurrentState(state.clients, loggingInfo); + } + throw error; + } + return state; + }; + + const reducer: AsyncReducer = combineReducersAsync({ + createSubDirectory: async ({ client }, { path, name }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.createSubDirectory(name); + }, + deleteSubDirectory: async ({ client }, { path, name }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.deleteSubDirectory(name); + }, + set: async ({ client }, { path, key, value }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.set(key, value); + }, + clear: async ({ client }, { path }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.clear(); + }, + delete: async ({ client }, { path, key }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.delete(key); + }, + }); + + return withLogging(reducer); +} + +/** + * The default options for the directory fuzz model + */ +const dirOptions: DirOperationGenerationConfig = { + setKeyWeight: 0, + clearKeysWeight: 0, + deleteKeyWeight: 0, + createSubDirWeight: 2, + deleteSubDirWeight: 2, + maxSubDirectoryChild: 2, + subDirectoryNamePool: ["dir1", "dir2"], + validateInterval: dirDefaultOptions.validateInterval, +}; + +/** + * The base fuzz model for directory + */ +export const baseDirModel: DDSFuzzModel = { + workloadName: "default directory 1", + generatorFactory: () => takeAsync(100, makeDirOperationGenerator(dirOptions)), + reducer: makeDirReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), + validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), + factory: new DirectoryFactory(), +}; diff --git a/packages/dds/map/src/test/mocha/index.ts b/packages/dds/map/src/test/mocha/index.ts index 1145af36f37b..110e48e4a35e 100644 --- a/packages/dds/map/src/test/mocha/index.ts +++ b/packages/dds/map/src/test/mocha/index.ts @@ -2,4 +2,4 @@ * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. */ -export { model } from "./map.fuzz.spec.js"; +export { baseDirModel,baseMapModel } from "./fuzzUtils.js"; diff --git a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts index 749b61eb3e56..260e8731c100 100644 --- a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts +++ b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts @@ -12,10 +12,10 @@ import { FlushMode } from "@fluidframework/runtime-definitions/internal"; import { _dirname } from "./dirname.cjs"; -import { mapBaseModel } from "./fuzzUtils.js"; +import { baseMapModel } from "./fuzzUtils.js"; describe("Map fuzz tests", () => { - createDDSFuzzSuite(mapBaseModel, { + createDDSFuzzSuite(baseMapModel, { defaultTestCount: 100, numberOfClients: 3, clientJoinOptions: { @@ -30,7 +30,7 @@ describe("Map fuzz tests", () => { }); createDDSFuzzSuite( - { ...mapBaseModel, workloadName: "with reconnect" }, + { ...baseMapModel, workloadName: "with reconnect" }, { defaultTestCount: 100, numberOfClients: 3, @@ -49,7 +49,7 @@ describe("Map fuzz tests", () => { ); createDDSFuzzSuite( - { ...mapBaseModel, workloadName: "with batches and rebasing" }, + { ...baseMapModel, workloadName: "with batches and rebasing" }, { defaultTestCount: 100, numberOfClients: 3, From e2f82b13736595d650693956f6bfd9c499a031f9 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 11:28:10 -0800 Subject: [PATCH 18/54] add comments --- packages/dds/map/src/test/mocha/fuzzUtils.ts | 52 +++++++++++++++----- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/dds/map/src/test/mocha/fuzzUtils.ts b/packages/dds/map/src/test/mocha/fuzzUtils.ts index a732e5c58582..b1d269d3af07 100644 --- a/packages/dds/map/src/test/mocha/fuzzUtils.ts +++ b/packages/dds/map/src/test/mocha/fuzzUtils.ts @@ -30,16 +30,25 @@ import { DirectoryFactory, type IDirectory, type ISharedMap, MapFactory } from " import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; +/** + * Represents a map clear operation. + */ interface MapClear { type: "clear"; } +/** + * Represents a map set key operation. + */ interface MapSetKey { type: "setKey"; key: string; value: Serializable; } +/** + * Represents a map delete key operation. + */ interface MapDeleteKey { type: "deleteKey"; key: string; @@ -85,6 +94,9 @@ const mapReducer = combineReducers({ }, }); +/** + * Represents the options for the map generator. + */ interface MapGeneratorOptions { setWeight: number; deleteWeight: number; @@ -148,7 +160,7 @@ export const baseMapModel: DDSFuzzModel = { type DirFuzzTestState = DDSFuzzTestState; /** - * + * Represents a directory set key operation. */ export interface DirSetKey { type: "set"; @@ -158,7 +170,7 @@ export interface DirSetKey { } /** - * + * Represents a directory clear keys operation. */ export interface DirClearKeys { type: "clear"; @@ -166,7 +178,7 @@ export interface DirClearKeys { } /** - * + * Represents a directory delete key operation. */ export interface DirDeleteKey { type: "delete"; @@ -175,7 +187,7 @@ export interface DirDeleteKey { } /** - * + * Represents a create subdirectory operation. */ export interface CreateSubDirectory { type: "createSubDirectory"; @@ -184,7 +196,7 @@ export interface CreateSubDirectory { } /** - * + * Represents a delete subdirectory operation. */ export interface DeleteSubDirectory { type: "deleteSubDirectory"; @@ -193,20 +205,23 @@ export interface DeleteSubDirectory { } /** - * + * Represents a directory key operation. */ export type DirKeyOperation = DirSetKey | DirDeleteKey | DirClearKeys; /** - * + * Represents a subdirectory operation. */ export type SubDirectoryOperation = CreateSubDirectory | DeleteSubDirectory; /** - * + * Represents a directory operation. */ export type DirOperation = DirKeyOperation | SubDirectoryOperation; +/** + * Represents the configuration for directory operation generation. + */ interface DirOperationGenerationConfig { validateInterval: number; maxSubDirectoryChild?: number; @@ -234,6 +249,12 @@ export const dirDefaultOptions: Required = { deleteSubDirWeight: 1, }; +/** + * Picks an absolute path for key operations. + * @param state - The current state of the directory fuzz test. + * @param shouldHaveKey - Whether the directory should have a key. + * @returns The absolute path. + */ function pickAbsolutePathForKeyOps(state: DirFuzzTestState, shouldHaveKey: boolean): string { const { random, client } = state; let parentDir: IDirectory = client.channel; @@ -254,7 +275,9 @@ function pickAbsolutePathForKeyOps(state: DirFuzzTestState, shouldHaveKey: boole } /** - * + * Creates a directory operation generator. + * @param optionsParam - The configuration options for the generator. + * @returns An asynchronous generator for directory operations. */ export function makeDirOperationGenerator( optionsParam?: DirOperationGenerationConfig, @@ -395,6 +418,9 @@ export function makeDirOperationGenerator( ]); } +/** + * Represents logging information. + */ interface LoggingInfo { // Clients to print clientIds: string[]; @@ -417,7 +443,9 @@ function logCurrentState(clients: Client[], loggingInfo: Loggi } /** - * + * Creates a directory reducer with optional logging. + * @param loggingInfo - The logging information. + * @returns An asynchronous reducer for directory operations. */ export function makeDirReducer(loggingInfo?: LoggingInfo): AsyncReducer { const withLogging = @@ -471,7 +499,7 @@ export function makeDirReducer(loggingInfo?: LoggingInfo): AsyncReducer = { workloadName: "default directory 1", From 162b6c80fd610db91ce2b102c6b315cf99a4f113 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 11:28:33 -0800 Subject: [PATCH 19/54] export sequence fuzz models --- packages/dds/sequence/package.json | 10 ++++ .../dds/sequence/src/test/fuzz/fuzzUtils.ts | 49 +++++++++++++++++ packages/dds/sequence/src/test/fuzz/index.ts | 6 ++ .../test/fuzz/intervalCollection.fuzz.spec.ts | 14 +---- .../src/test/fuzz/sharedString.fuzz.spec.ts | 55 +------------------ packages/dds/sequence/src/test/index.ts | 6 ++ packages/dds/sequence/src/test/tsconfig.json | 4 ++ 7 files changed, 77 insertions(+), 67 deletions(-) create mode 100644 packages/dds/sequence/src/test/fuzz/index.ts create mode 100644 packages/dds/sequence/src/test/index.ts diff --git a/packages/dds/sequence/package.json b/packages/dds/sequence/package.json index 9338b157ef80..c4f93b7b4e41 100644 --- a/packages/dds/sequence/package.json +++ b/packages/dds/sequence/package.json @@ -52,6 +52,16 @@ "types": "./dist/intervalCollection.d.ts", "default": "./dist/intervalCollection.js" } + }, + "./internal/test": { + "import": { + "types": "./lib/test/index.d.ts", + "default": "./lib/test/index.js" + }, + "require": { + "types": "./dist/test/index.d.ts", + "default": "./dist/test/index.js" + } } }, "main": "lib/index.js", diff --git a/packages/dds/sequence/src/test/fuzz/fuzzUtils.ts b/packages/dds/sequence/src/test/fuzz/fuzzUtils.ts index a6fb264f810f..d0b0164165bf 100644 --- a/packages/dds/sequence/src/test/fuzz/fuzzUtils.ts +++ b/packages/dds/sequence/src/test/fuzz/fuzzUtils.ts @@ -12,6 +12,7 @@ import { AsyncReducer, combineReducersAsync, createWeightedAsyncGenerator, + takeAsync, } from "@fluid-private/stochastic-test-utils"; import { DDSFuzzModel, @@ -648,3 +649,51 @@ export function makeIntervalOperationGenerator( [changeInterval, usableWeights.changeInterval, all(hasAnInterval, hasNonzeroLength)], ]); } + +export const baseIntervalModel = { + ...baseModel, + generatorFactory: () => + takeAsync(100, makeIntervalOperationGenerator(defaultIntervalOperationGenerationConfig)), +}; + +export function makeSharedStringOperationGenerator( + optionsParam?: SharedStringOperationGenerationConfig, + alwaysLeaveChar: boolean = false, +): AsyncGenerator { + const { + addText, + removeRange, + annotateRange, + annotateAdjustRange, + removeRangeLeaveChar, + lengthSatisfies, + hasNonzeroLength, + isShorterThanMaxLength, + } = createSharedStringGeneratorOperations(optionsParam); + + const usableWeights = + optionsParam?.weights ?? defaultIntervalOperationGenerationConfig.weights; + return createWeightedAsyncGenerator([ + [addText, usableWeights.addText, isShorterThanMaxLength], + [ + alwaysLeaveChar ? removeRangeLeaveChar : removeRange, + usableWeights.removeRange, + alwaysLeaveChar + ? lengthSatisfies((length) => { + return length > 1; + }) + : hasNonzeroLength, + ], + [annotateRange, usableWeights.annotateRange, hasNonzeroLength], + [annotateAdjustRange, usableWeights.annotateRange, hasNonzeroLength], + ]); +} + +export const baseSharedStringModel= { + ...baseModel, + generatorFactory: () => + takeAsync( + 100, + makeSharedStringOperationGenerator(defaultIntervalOperationGenerationConfig), + ), +}; diff --git a/packages/dds/sequence/src/test/fuzz/index.ts b/packages/dds/sequence/src/test/fuzz/index.ts new file mode 100644 index 000000000000..817675bf6969 --- /dev/null +++ b/packages/dds/sequence/src/test/fuzz/index.ts @@ -0,0 +1,6 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export { baseIntervalModel, baseSharedStringModel } from "./fuzzUtils.js"; diff --git a/packages/dds/sequence/src/test/fuzz/intervalCollection.fuzz.spec.ts b/packages/dds/sequence/src/test/fuzz/intervalCollection.fuzz.spec.ts index 58d512dad004..4c2bceb0b32e 100644 --- a/packages/dds/sequence/src/test/fuzz/intervalCollection.fuzz.spec.ts +++ b/packages/dds/sequence/src/test/fuzz/intervalCollection.fuzz.spec.ts @@ -3,22 +3,10 @@ * Licensed under the MIT License. */ -import { takeAsync } from "@fluid-private/stochastic-test-utils"; import { createDDSFuzzSuite } from "@fluid-private/test-dds-utils"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; -import { - baseModel, - defaultFuzzOptions, - defaultIntervalOperationGenerationConfig, - makeIntervalOperationGenerator, -} from "./fuzzUtils.js"; - -const baseIntervalModel = { - ...baseModel, - generatorFactory: () => - takeAsync(100, makeIntervalOperationGenerator(defaultIntervalOperationGenerationConfig)), -}; +import { defaultFuzzOptions, baseIntervalModel } from "./fuzzUtils.js"; describe("IntervalCollection fuzz testing", () => { const model = { diff --git a/packages/dds/sequence/src/test/fuzz/sharedString.fuzz.spec.ts b/packages/dds/sequence/src/test/fuzz/sharedString.fuzz.spec.ts index a8c88c11e944..685670c0b184 100644 --- a/packages/dds/sequence/src/test/fuzz/sharedString.fuzz.spec.ts +++ b/packages/dds/sequence/src/test/fuzz/sharedString.fuzz.spec.ts @@ -3,63 +3,10 @@ * Licensed under the MIT License. */ -import { - AsyncGenerator as Generator, - createWeightedAsyncGenerator as createWeightedGenerator, - takeAsync as take, -} from "@fluid-private/stochastic-test-utils"; import { createDDSFuzzSuite } from "@fluid-private/test-dds-utils"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; -import { - FuzzTestState, - Operation, - SharedStringOperationGenerationConfig, - baseModel, - createSharedStringGeneratorOperations, - defaultFuzzOptions, - defaultIntervalOperationGenerationConfig, -} from "./fuzzUtils.js"; - -type ClientOpState = FuzzTestState; -export function makeSharedStringOperationGenerator( - optionsParam?: SharedStringOperationGenerationConfig, - alwaysLeaveChar: boolean = false, -): Generator { - const { - addText, - removeRange, - annotateRange, - annotateAdjustRange, - removeRangeLeaveChar, - lengthSatisfies, - hasNonzeroLength, - isShorterThanMaxLength, - } = createSharedStringGeneratorOperations(optionsParam); - - const usableWeights = - optionsParam?.weights ?? defaultIntervalOperationGenerationConfig.weights; - return createWeightedGenerator([ - [addText, usableWeights.addText, isShorterThanMaxLength], - [ - alwaysLeaveChar ? removeRangeLeaveChar : removeRange, - usableWeights.removeRange, - alwaysLeaveChar - ? lengthSatisfies((length) => { - return length > 1; - }) - : hasNonzeroLength, - ], - [annotateRange, usableWeights.annotateRange, hasNonzeroLength], - [annotateAdjustRange, usableWeights.annotateRange, hasNonzeroLength], - ]); -} - -const baseSharedStringModel = { - ...baseModel, - generatorFactory: () => - take(100, makeSharedStringOperationGenerator(defaultIntervalOperationGenerationConfig)), -}; +import { baseSharedStringModel, defaultFuzzOptions } from "./fuzzUtils.js"; describe("SharedString fuzz testing", () => { createDDSFuzzSuite( diff --git a/packages/dds/sequence/src/test/index.ts b/packages/dds/sequence/src/test/index.ts new file mode 100644 index 000000000000..74a01289278b --- /dev/null +++ b/packages/dds/sequence/src/test/index.ts @@ -0,0 +1,6 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export { baseIntervalModel, baseSharedStringModel } from "./fuzz/index.js"; diff --git a/packages/dds/sequence/src/test/tsconfig.json b/packages/dds/sequence/src/test/tsconfig.json index 9973828d87bf..6019c789c9b2 100644 --- a/packages/dds/sequence/src/test/tsconfig.json +++ b/packages/dds/sequence/src/test/tsconfig.json @@ -7,6 +7,10 @@ "noImplicitAny": false, "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, + // The sequence package uses test code from merge-tree, so we need to build types for test files, which we typically + // don't do. + "declaration": true, + "declarationMap": true, }, "include": ["./**/*"], "references": [ From 0652c31828678b96f5531df2b29be778b7907ef1 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 11:37:20 -0800 Subject: [PATCH 20/54] integrate multiple dds models --- .../src/test/mocha/directoryFuzzTests.spec.ts | 2 +- .../src/localServerStressHarness.ts | 10 +++- .../src/test/localServerStress.spec.ts | 60 +++++++++++-------- pnpm-lock.yaml | 2 +- 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts index 681c641d3c4f..bf4022de5e99 100644 --- a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts +++ b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts @@ -18,7 +18,7 @@ import { DirectoryFactory } from "../../index.js"; import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; import { _dirname } from "./dirname.cjs"; -import { baseDirModel, dirDefaultOptions } from "./fuzzUtils.js"; +import { baseDirModel, dirDefaultOptions, makeDirOperationGenerator, makeDirReducer, type DirOperation } from "./fuzzUtils.js"; describe("SharedDirectory fuzz Create/Delete concentrated", () => { diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index fabaaa924717..d436dce0fd06 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -674,7 +674,7 @@ function mixinSynchronization< ); if (connectedClients.length > 0) { - const readonlyChannel = state.clients[0]; + const readonlyChannel = connectedClients[0]; for (const client of connectedClients) { try { await model.validateConsistency(readonlyChannel, client); @@ -889,8 +889,7 @@ export class StressDataObject extends DataObject { return this.runtime.attachState === AttachState.Attached; } - public channels: Record IChannel> = { - root: () => this.root, + public channels: Record = { }; protected async preInitialize(): Promise { @@ -898,6 +897,9 @@ export class StressDataObject extends DataObject { this._globalObjects = root._globalObjects; + const channels = this.channels[this.root.attributes.type] ??=[]; + channels.push(this.root); + setTimeout(() => { this._globalObjects[this.id] = { type: "stressDataObject", @@ -943,6 +945,8 @@ class DefaultStressDataObject extends StressDataObject { } protected async preInitialize(): Promise { + const channels = this.channels[this.root.attributes.type] ??=[]; + channels.push(this.root); this._globalObjects[this.id] = { type: "stressDataObject", StressDataObject: this, diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index e7269db922ea..4b57cad8fbd6 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -9,7 +9,6 @@ import { type AsyncGenerator, combineReducersAsync, createWeightedAsyncGenerator, - done, takeAsync, } from "@fluid-private/stochastic-test-utils"; import { DDSFuzzModel } from "@fluid-private/test-dds-utils"; @@ -17,9 +16,9 @@ import { fluidHandleSymbol, type IFluidHandle } from "@fluidframework/core-inter import { assert } from "@fluidframework/core-utils/internal"; import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; -import type { ISharedMap } from "@fluidframework/map/internal"; -import { model as MapFuzzModel } from "@fluidframework/map/internal/test"; +import { baseMapModel,baseDirModel } from "@fluidframework/map/internal/test"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; +import { baseSharedStringModel,baseIntervalModel } from "@fluidframework/sequence/internal/test"; import { Client, @@ -42,12 +41,14 @@ interface CreateDataStore { type: "createDataStore"; } -interface MapModel { - type: "mapModel"; +interface DDSModelOp { + type: "DDSModelOp"; + channelType:string; + channelId: string; op: unknown; } -type StressOperations = UploadBlob | AliasDataStore | CreateDataStore | MapModel; +type StressOperations = UploadBlob | AliasDataStore | CreateDataStore | DDSModelOp; const reducer = combineReducersAsync({ aliasDataStore: async (state, op) => { @@ -65,12 +66,16 @@ const reducer = combineReducersAsync({ state.random.string(state.random.integer(1, 246)), ); }, - mapModel: async (state, op) => { - await MapFuzzModel.reducer( + DDSModelOp: async (state, op) => { + const baseModel = ddsModelMap.get(op.channelType); + assert(baseModel !== undefined, "must have model"); + const channel = state.client.entryPoint.channels[op.channelType].find((v)=>v.id===op.channelId); + assert(channel !== undefined, "must have channel"); + await baseModel.reducer( { clients: makeUnreachableCodePathProxy("clients"), client: { - channel: state.client.entryPoint.channels.root() as ISharedMap, + channel, containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), }, @@ -108,12 +113,18 @@ function makeGenerator(): AsyncGenerator = async (state) => { - const op = await mapGenerator({ + const DDSModelOp: AsyncGenerator = async (state) => { + + const channelType = state.random.pick(Object.keys(state.client.entryPoint.channels)); + const channel= state.random.pick(state.client.entryPoint.channels[channelType]); + const model = ddsModelMap.get(channelType) + const generator = model?.generatorFactory(); + assert(generator !== undefined, "must have model"); + + const op = await generator({ clients: makeUnreachableCodePathProxy("clients"), client: { - channel: state.client.entryPoint.channels.root() as ISharedMap, + channel, containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), }, @@ -143,15 +154,12 @@ function makeGenerator(): AsyncGenerator( @@ -166,7 +174,7 @@ function makeGenerator(): AsyncGenerator>(); -ddsModelMap.set(MapFuzzModel.factory.type, MapFuzzModel); +const ddsModelMap = new Map,"workloadName">>(); +ddsModelMap.set(baseMapModel.factory.type, baseMapModel); +ddsModelMap.set(baseDirModel.factory.type, baseDirModel); +ddsModelMap.set(baseSharedStringModel.factory.type, baseSharedStringModel); +ddsModelMap.set(baseIntervalModel.factory.type, baseIntervalModel); const validateConsistency = async (clientA: Client, clientB: Client) => { const buildChannelMap = (client: Client) => { @@ -185,8 +196,7 @@ const validateConsistency = async (clientA: Client, clientB: Client) => { v.type === "stressDataObject" ? v : undefined, )) { if (value?.StressDataObject.attached) { - for (const channelF of Object.values(value.StressDataObject.channels)) { - const channel = channelF(); + for (const channel of Object.values(value.StressDataObject.channels).flatMap((ca)=>ca)) { if (channel.isAttached()) { channelMap.set(`${value.StressDataObject.id}/${channel.id}`, channel); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e886aeb3904c..cfb2d830809e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13788,7 +13788,7 @@ importers: version: link:../../loader/driver-utils '@fluidframework/eslint-config-fluid': specifier: ^5.6.0 - version: 5.6.0(eslint@8.55.0)(typescript@5.4.5) + version: 5.7.3(eslint@8.55.0)(typescript@5.4.5) '@fluidframework/id-compressor': specifier: workspace:~ version: link:../../runtime/id-compressor From 2d81fdff34f0b488a92f1eaed1fd8250973ba8a7 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 12:15:30 -0800 Subject: [PATCH 21/54] break files apart --- .../src/ddsModels.ts | 132 ++++++++++++++ .../src/localServerStressHarness.ts | 163 +---------------- .../src/stressDataObject.ts | 170 ++++++++++++++++++ .../src/test/localServerStress.spec.ts | 74 +------- 4 files changed, 311 insertions(+), 228 deletions(-) create mode 100644 packages/test/local-server-stress-tests/src/ddsModels.ts create mode 100644 packages/test/local-server-stress-tests/src/stressDataObject.ts diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts new file mode 100644 index 000000000000..f8f217433ebe --- /dev/null +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -0,0 +1,132 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + + +import { + done, + type AsyncGenerator, + type AsyncReducer, +} from "@fluid-private/stochastic-test-utils"; +import { DDSFuzzModel,DDSFuzzTestState } from "@fluid-private/test-dds-utils"; +import { IFluidHandle, fluidHandleSymbol } from "@fluidframework/core-interfaces"; +import { assert } from "@fluidframework/core-utils/internal"; +import type { IChannel, IChannelFactory } from "@fluidframework/datastore-definitions/internal"; +// eslint-disable-next-line import/no-internal-modules +import { baseMapModel,baseDirModel } from "@fluidframework/map/internal/test"; +// eslint-disable-next-line import/no-internal-modules +import { baseSharedStringModel,baseIntervalModel } from "@fluidframework/sequence/internal/test"; + +import { LocalServerStressState, makeUnreachableCodePathProxy } from "./localServerStressHarness"; + + +export function repeatFactoryAsync(factory: ()=>AsyncGenerator): AsyncGenerator { + let generator = factory(); + return async (state: TState) => { + const next = await generator(state) + if(next !== done){ + return next; + } + generator = factory() + return generator(state); + }; +} + + +const generateSubModelMap =(...models: Omit,"workloadName">[])=>{ + const modelMap = new Map>, + reducer: DDSFuzzModel["reducer"], + validateConsistency: DDSFuzzModel["validateConsistency"], + minimizationTransforms?: DDSFuzzModel["minimizationTransforms"] + }>() + for(const model of models){ + const {reducer, generatorFactory, factory, validateConsistency, minimizationTransforms} = model; + const generator =repeatFactoryAsync(generatorFactory); + modelMap.set(factory.attributes.type, { + generator, + reducer, + factory, + validateConsistency, + minimizationTransforms + } +); + } + + return modelMap; +} + +export const ddsModelMap = generateSubModelMap(baseMapModel, baseDirModel, baseSharedStringModel,baseIntervalModel) + + +export interface DDSModelOp { + type: "DDSModelOp"; + channelType:string; + channelId: string; + op: unknown; +} + +const covertLocalServerStateToDdsState=(state: LocalServerStressState, channel: IChannel): DDSFuzzTestState=>{ + return { + clients: makeUnreachableCodePathProxy("clients"), + client: { + channel, + containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), + dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), + }, + containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), + isDetached: state.isDetached, + summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), + random: { + ...state.random, + handle: () => { + const realHandle = state.random.pick( + Object.values(state.client.entryPoint.globalObjects) + .map((v) => v.handle) + .filter((v): v is IFluidHandle => v !== undefined), + ); + return { + get [fluidHandleSymbol]() { + return realHandle[fluidHandleSymbol]; + }, + async get() { + return realHandle.get(); + }, + get isAttached() { + return realHandle.isAttached; + }, + }; + }, + }, + } +} + + +export const DDSModelOpGenerator: AsyncGenerator = async (state) => { + + const channelType = state.random.pick(Object.keys(state.client.entryPoint.channels)); + const channel= state.random.pick(state.client.entryPoint.channels[channelType]); + const model = ddsModelMap.get(channelType) + assert(model !== undefined, "must have model"); + + const op = await model.generator(covertLocalServerStateToDdsState(state, channel)); + + return { + type: "DDSModelOp", + channelType, + channelId: channel.id, + op, + } satisfies DDSModelOp; + }; + + export const DDSModelOpReducer: AsyncReducer = async (state, op)=> { + const baseModel = ddsModelMap.get(op.channelType); + assert(baseModel !== undefined, "must have model"); + const channel = state.client.entryPoint.channels[op.channelType].find((v)=>v.id===op.channelId); + assert(channel !== undefined, "must have channel"); + await baseModel.reducer(covertLocalServerStateToDdsState(state, channel), + op.op as any, + ); + }; diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index d436dce0fd06..1577cacc4fdf 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -7,7 +7,7 @@ import { strict as assert } from "node:assert"; import { mkdirSync, readFileSync } from "node:fs"; import path from "node:path"; -import { stringToBuffer, TypedEventEmitter } from "@fluid-internal/client-utils"; +import { TypedEventEmitter } from "@fluid-internal/client-utils"; import type { AsyncGenerator, AsyncReducer, @@ -30,30 +30,23 @@ import { saveOpsToFile, takeAsync, } from "@fluid-private/stochastic-test-utils"; -import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal"; import { - AttachState, type ICodeDetailsLoader, type IContainer, type IFluidCodeDetails, - type IRuntimeFactory, } from "@fluidframework/container-definitions/internal"; import { ConnectionState, createDetachedContainer, loadExistingContainer, } from "@fluidframework/container-loader/internal"; -import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; -import type { FluidObject } from "@fluidframework/core-interfaces"; +import type { FluidObject } from "@fluidframework/core-interfaces"; import { unreachableCase } from "@fluidframework/core-utils/internal"; -import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import { createLocalResolverCreateNewRequest, LocalDocumentServiceFactory, LocalResolver, } from "@fluidframework/local-driver/internal"; -import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { ILocalDeltaConnectionServer, LocalDeltaConnectionServer, @@ -62,6 +55,7 @@ import { LocalCodeLoader } from "@fluidframework/test-utils/internal"; import { FuzzTestMinimizer } from "./minification.js"; import type { MinimizationTransform } from "./minification.js"; +import {runtimeFactory, StressDataObject} from "./stressDataObject.js" const isOperationType = ( type: O["type"], @@ -843,157 +837,6 @@ function makeFriendlyClientId(random: IRandom, index: number): string { return index < 26 ? String.fromCodePoint(index + 65) : random.uuid4(); } -export class StressDataObject extends DataObject { - get StressDataObject() { - return this; - } - - protected _globalObjects: Record< - string, - | { type: "newBlob"; handle: IFluidHandle } - | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } - | { - type: "stressDataObject"; - StressDataObject: StressDataObject; - handle: IFluidHandle; - } - | { type: "newAlias"; alias: string } - > = {}; - - public get globalObjects(): Readonly< - Record< - string, - | { type: "newBlob"; handle: IFluidHandle } - | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } - | { - type: "stressDataObject"; - StressDataObject: StressDataObject; - handle: IFluidHandle; - } - | { type: "newAlias"; alias: string; handle?: undefined } - > - > { - return this._globalObjects; - } - - protected async getDefaultStressDataObject() { - const root = await this.context.containerRuntime.getAliasedDataStoreEntryPoint("default"); - assert(root !== undefined, "default must exist"); - - const maybe: FluidObject | undefined = await root.get(); - assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); - return maybe.StressDataObject; - } - - public get attached() { - return this.runtime.attachState === AttachState.Attached; - } - - public channels: Record = { - }; - - protected async preInitialize(): Promise { - const root = await this.getDefaultStressDataObject(); - - this._globalObjects = root._globalObjects; - - const channels = this.channels[this.root.attributes.type] ??=[]; - channels.push(this.root); - - setTimeout(() => { - this._globalObjects[this.id] = { - type: "stressDataObject", - StressDataObject: this, - handle: this.handle, - }; - }, 0); - } - - public uploadBlob(id: string, contents: string) { - void this.runtime - .uploadBlob(stringToBuffer(contents, "utf-8")) - .then( - (blobHandle) => (this._globalObjects[id] = { type: "newBlob", handle: blobHandle }), - ); - } - - public createDataStore(id: string) { - void this.context.containerRuntime - .createDataStore(stressDataObjectFactory.type) - .then(async (dataStore) => { - this._globalObjects[id] = { - type: "newDatastore", - dataStore, - handle: dataStore.entryPoint, - }; - }); - } -} - -const stressDataObjectFactory = new DataObjectFactory( - "StressDataObject", - StressDataObject, - undefined, - {}, -); - -class DefaultStressDataObject extends StressDataObject { - public static readonly alias = "default"; - - protected override async getDefaultStressDataObject(): Promise { - return this; - } - - protected async preInitialize(): Promise { - const channels = this.channels[this.root.attributes.type] ??=[]; - channels.push(this.root); - this._globalObjects[this.id] = { - type: "stressDataObject", - StressDataObject: this, - handle: this.handle, - }; - this._globalObjects.default = { type: "newAlias", alias: DefaultStressDataObject.alias }; - } -} - -const defaultStressDataObjectFactory = new DataObjectFactory( - "DefaultStressDataObject", - DefaultStressDataObject, - undefined, - {}, -); - -const runtimeFactory: IRuntimeFactory = { - get IRuntimeFactory() { - return this; - }, - instantiateRuntime: async (context, existing) => { - return loadContainerRuntime({ - context, - existing, - registryEntries: [ - [defaultStressDataObjectFactory.type, Promise.resolve(defaultStressDataObjectFactory)], - [stressDataObjectFactory.type, Promise.resolve(stressDataObjectFactory)], - ], - provideEntryPoint: async (rt) => { - const maybeDefault = await rt.getAliasedDataStoreEntryPoint( - DefaultStressDataObject.alias, - ); - if (maybeDefault === undefined) { - const ds = await rt.createDataStore(defaultStressDataObjectFactory.type); - await ds.trySetAlias(DefaultStressDataObject.alias); - } - const aliasedDefault = await rt.getAliasedDataStoreEntryPoint( - DefaultStressDataObject.alias, - ); - assert(aliasedDefault !== undefined, "default must exist"); - - const maybe: FluidObject | undefined = await aliasedDefault.get(); - return maybe; - }, - }); - }, -}; /** * Runs the provided DDS fuzz model. All functionality is already assumed to be mixed in. diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts new file mode 100644 index 000000000000..56aaaa165d8f --- /dev/null +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -0,0 +1,170 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { stringToBuffer } from "@fluid-internal/client-utils"; +import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal"; +import { + AttachState, + type IRuntimeFactory, +} from "@fluidframework/container-definitions/internal"; +import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; +import type { IFluidHandle } from "@fluidframework/core-interfaces"; +import type { FluidObject } from "@fluidframework/core-interfaces"; +import { assert } from "@fluidframework/core-utils/internal"; +import type { IChannel } from "@fluidframework/datastore-definitions/internal"; +import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; + + +export class StressDataObject extends DataObject { + get StressDataObject() { + return this; + } + + protected _globalObjects: Record< + string, + | { type: "newBlob"; handle: IFluidHandle } + | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } + | { + type: "stressDataObject"; + StressDataObject: StressDataObject; + handle: IFluidHandle; + } + | { type: "newAlias"; alias: string } + > = {}; + + public get globalObjects(): Readonly< + Record< + string, + | { type: "newBlob"; handle: IFluidHandle } + | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } + | { + type: "stressDataObject"; + StressDataObject: StressDataObject; + handle: IFluidHandle; + } + | { type: "newAlias"; alias: string; handle?: undefined } + > + > { + return this._globalObjects; + } + + protected async getDefaultStressDataObject() { + const root = await this.context.containerRuntime.getAliasedDataStoreEntryPoint("default"); + assert(root !== undefined, "default must exist"); + + const maybe: FluidObject | undefined = await root.get(); + assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); + return maybe.StressDataObject; + } + + public get attached() { + return this.runtime.attachState === AttachState.Attached; + } + + public channels: Record = { + }; + + protected async preInitialize(): Promise { + const root = await this.getDefaultStressDataObject(); + + this._globalObjects = root._globalObjects; + + const channels = this.channels[this.root.attributes.type] ??=[]; + channels.push(this.root); + + setTimeout(() => { + this._globalObjects[this.id] = { + type: "stressDataObject", + StressDataObject: this, + handle: this.handle, + }; + }, 0); + } + + public uploadBlob(id: string, contents: string) { + void this.runtime + .uploadBlob(stringToBuffer(contents, "utf-8")) + .then( + (blobHandle) => (this._globalObjects[id] = { type: "newBlob", handle: blobHandle }), + ); + } + + public createDataStore(id: string) { + void this.context.containerRuntime + .createDataStore(stressDataObjectFactory.type) + .then(async (dataStore) => { + this._globalObjects[id] = { + type: "newDatastore", + dataStore, + handle: dataStore.entryPoint, + }; + }); + } +} + +const stressDataObjectFactory = new DataObjectFactory( + "StressDataObject", + StressDataObject, + undefined, + {}, +); + +class DefaultStressDataObject extends StressDataObject { + public static readonly alias = "default"; + + protected override async getDefaultStressDataObject(): Promise { + return this; + } + + protected async preInitialize(): Promise { + const channels = this.channels[this.root.attributes.type] ??=[]; + channels.push(this.root); + this._globalObjects[this.id] = { + type: "stressDataObject", + StressDataObject: this, + handle: this.handle, + }; + this._globalObjects.default = { type: "newAlias", alias: DefaultStressDataObject.alias }; + } +} + +export const defaultStressDataObjectFactory = new DataObjectFactory( + "DefaultStressDataObject", + DefaultStressDataObject, + undefined, + {}, +); + +export const runtimeFactory: IRuntimeFactory = { + get IRuntimeFactory() { + return this; + }, + instantiateRuntime: async (context, existing) => { + return loadContainerRuntime({ + context, + existing, + registryEntries: [ + [defaultStressDataObjectFactory.type, Promise.resolve(defaultStressDataObjectFactory)], + [stressDataObjectFactory.type, Promise.resolve(stressDataObjectFactory)], + ], + provideEntryPoint: async (rt) => { + const maybeDefault = await rt.getAliasedDataStoreEntryPoint( + DefaultStressDataObject.alias, + ); + if (maybeDefault === undefined) { + const ds = await rt.createDataStore(defaultStressDataObjectFactory.type); + await ds.trySetAlias(DefaultStressDataObject.alias); + } + const aliasedDefault = await rt.getAliasedDataStoreEntryPoint( + DefaultStressDataObject.alias, + ); + assert(aliasedDefault !== undefined, "default must exist"); + + const maybe: FluidObject | undefined = await aliasedDefault.get(); + return maybe; + }, + }); + }, +}; diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 4b57cad8fbd6..af5c6826c09c 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -11,15 +11,12 @@ import { createWeightedAsyncGenerator, takeAsync, } from "@fluid-private/stochastic-test-utils"; -import { DDSFuzzModel } from "@fluid-private/test-dds-utils"; -import { fluidHandleSymbol, type IFluidHandle } from "@fluidframework/core-interfaces"; +import { type IFluidHandle } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; -import type { IChannel } from "@fluidframework/datastore-definitions/internal"; -import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; -import { baseMapModel,baseDirModel } from "@fluidframework/map/internal/test"; +import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; -import { baseSharedStringModel,baseIntervalModel } from "@fluidframework/sequence/internal/test"; +import {ddsModelMap, DDSModelOpGenerator, type DDSModelOp} from "../ddsModels.js" import { Client, createLocalServerStressSuite, @@ -41,13 +38,6 @@ interface CreateDataStore { type: "createDataStore"; } -interface DDSModelOp { - type: "DDSModelOp"; - channelType:string; - channelId: string; - op: unknown; -} - type StressOperations = UploadBlob | AliasDataStore | CreateDataStore | DDSModelOp; const reducer = combineReducersAsync({ @@ -113,54 +103,6 @@ function makeGenerator(): AsyncGenerator = async (state) => { - - const channelType = state.random.pick(Object.keys(state.client.entryPoint.channels)); - const channel= state.random.pick(state.client.entryPoint.channels[channelType]); - const model = ddsModelMap.get(channelType) - const generator = model?.generatorFactory(); - assert(generator !== undefined, "must have model"); - - const op = await generator({ - clients: makeUnreachableCodePathProxy("clients"), - client: { - channel, - containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), - dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), - }, - containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), - isDetached: state.isDetached, - summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), - random: { - ...state.random, - handle: () => { - const realHandle = state.random.pick( - Object.values(state.client.entryPoint.globalObjects) - .map((v) => v.handle) - .filter((v): v is IFluidHandle => v !== undefined), - ); - return { - get [fluidHandleSymbol]() { - return realHandle[fluidHandleSymbol]; - }, - async get() { - return realHandle.get(); - }, - get isAttached() { - return realHandle.isAttached; - }, - }; - }, - }, - }); - - return { - type: "DDSModelOp", - channelType, - channelId: channel.id, - op, - } satisfies DDSModelOp; - }; const syncGenerator = createWeightedAsyncGenerator( [ @@ -174,7 +116,7 @@ function makeGenerator(): AsyncGenerator,"workloadName">>(); -ddsModelMap.set(baseMapModel.factory.type, baseMapModel); -ddsModelMap.set(baseDirModel.factory.type, baseDirModel); -ddsModelMap.set(baseSharedStringModel.factory.type, baseSharedStringModel); -ddsModelMap.set(baseIntervalModel.factory.type, baseIntervalModel); + const validateConsistency = async (clientA: Client, clientB: Client) => { const buildChannelMap = (client: Client) => { @@ -232,7 +170,7 @@ const validateConsistency = async (clientA: Client, clientB: Client) => { describe("Local Server Stress", () => { const model: LocalServerStressModel = { workloadName: "default", - generatorFactory: () => takeAsync(100, makeGenerator()), + generatorFactory: () => takeAsync(1000, makeGenerator()), reducer: async (state, operation) => reducer(state, operation), validateConsistency, }; From ca6d1211d90adcf375babc0a4789bf911dc1e933 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 12:28:13 -0800 Subject: [PATCH 22/54] move validation --- .../src/ddsModels.ts | 193 ++++++++++++------ .../src/stressDataObject.ts | 27 +-- .../src/test/localServerStress.spec.ts | 91 +-------- 3 files changed, 153 insertions(+), 158 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index f8f217433ebe..431309f20307 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -3,79 +3,108 @@ * Licensed under the MIT License. */ - import { done, type AsyncGenerator, type AsyncReducer, } from "@fluid-private/stochastic-test-utils"; -import { DDSFuzzModel,DDSFuzzTestState } from "@fluid-private/test-dds-utils"; +import { + DDSFuzzModel, + DDSFuzzTestState, + Client as DDSClient, +} from "@fluid-private/test-dds-utils"; import { IFluidHandle, fluidHandleSymbol } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; -import type { IChannel, IChannelFactory } from "@fluidframework/datastore-definitions/internal"; +import type { + IChannel, + IChannelFactory, +} from "@fluidframework/datastore-definitions/internal"; // eslint-disable-next-line import/no-internal-modules -import { baseMapModel,baseDirModel } from "@fluidframework/map/internal/test"; -// eslint-disable-next-line import/no-internal-modules -import { baseSharedStringModel,baseIntervalModel } from "@fluidframework/sequence/internal/test"; - -import { LocalServerStressState, makeUnreachableCodePathProxy } from "./localServerStressHarness"; - +import { baseMapModel, baseDirModel } from "@fluidframework/map/internal/test"; +import { + baseSharedStringModel, + baseIntervalModel, + // eslint-disable-next-line import/no-internal-modules +} from "@fluidframework/sequence/internal/test"; -export function repeatFactoryAsync(factory: ()=>AsyncGenerator): AsyncGenerator { +import { + LocalServerStressState, + makeUnreachableCodePathProxy, + Client, +} from "./localServerStressHarness"; + +export function repeatFactoryAsync( + factory: () => AsyncGenerator, +): AsyncGenerator { let generator = factory(); return async (state: TState) => { - const next = await generator(state) - if(next !== done){ + const next = await generator(state); + if (next !== done) { return next; } - generator = factory() + generator = factory(); return generator(state); }; } - -const generateSubModelMap =(...models: Omit,"workloadName">[])=>{ - const modelMap = new Map>, - reducer: DDSFuzzModel["reducer"], - validateConsistency: DDSFuzzModel["validateConsistency"], - minimizationTransforms?: DDSFuzzModel["minimizationTransforms"] - }>() - for(const model of models){ - const {reducer, generatorFactory, factory, validateConsistency, minimizationTransforms} = model; - const generator =repeatFactoryAsync(generatorFactory); - modelMap.set(factory.attributes.type, { +const generateSubModelMap = ( + ...models: Omit, "workloadName">[] +) => { + const modelMap = new Map< + string, + { + factory: IChannelFactory; + generator: AsyncGenerator>; + reducer: DDSFuzzModel["reducer"]; + validateConsistency: DDSFuzzModel["validateConsistency"]; + minimizationTransforms?: DDSFuzzModel["minimizationTransforms"]; + } + >(); + for (const model of models) { + const { reducer, generatorFactory, factory, validateConsistency, minimizationTransforms } = + model; + const generator = repeatFactoryAsync(generatorFactory); + modelMap.set(factory.attributes.type, { generator, reducer, factory, validateConsistency, - minimizationTransforms - } -); + minimizationTransforms, + }); } return modelMap; -} - -export const ddsModelMap = generateSubModelMap(baseMapModel, baseDirModel, baseSharedStringModel,baseIntervalModel) +}; +export const ddsModelMap = generateSubModelMap( + baseMapModel, + baseDirModel, + baseSharedStringModel, + baseIntervalModel, +); export interface DDSModelOp { type: "DDSModelOp"; - channelType:string; + channelType: string; channelId: string; op: unknown; } -const covertLocalServerStateToDdsState=(state: LocalServerStressState, channel: IChannel): DDSFuzzTestState=>{ +const createDDSClient = (channel: IChannel): DDSClient => { + return { + channel, + containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), + dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), + }; +}; + +const covertLocalServerStateToDdsState = ( + state: LocalServerStressState, + channel: IChannel, +): DDSFuzzTestState => { return { clients: makeUnreachableCodePathProxy("clients"), - client: { - channel, - containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), - dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), - }, + client: createDDSClient(channel), containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), isDetached: state.isDetached, summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), @@ -100,33 +129,67 @@ const covertLocalServerStateToDdsState=(state: LocalServerStressState, channel: }; }, }, - } -} - - -export const DDSModelOpGenerator: AsyncGenerator = async (state) => { + }; +}; - const channelType = state.random.pick(Object.keys(state.client.entryPoint.channels)); - const channel= state.random.pick(state.client.entryPoint.channels[channelType]); - const model = ddsModelMap.get(channelType) - assert(model !== undefined, "must have model"); +export const DDSModelOpGenerator: AsyncGenerator = async ( + state, +) => { + const channelType = state.random.pick(Object.keys(state.client.entryPoint.channels)); + const channel = state.random.pick(state.client.entryPoint.channels[channelType]); + const model = ddsModelMap.get(channelType); + assert(model !== undefined, "must have model"); - const op = await model.generator(covertLocalServerStateToDdsState(state, channel)); + const op = await model.generator(covertLocalServerStateToDdsState(state, channel)); - return { - type: "DDSModelOp", - channelType, - channelId: channel.id, - op, - } satisfies DDSModelOp; + return { + type: "DDSModelOp", + channelType, + channelId: channel.id, + op, + } satisfies DDSModelOp; +}; + +export const DDSModelOpReducer: AsyncReducer = async ( + state, + op, +) => { + const baseModel = ddsModelMap.get(op.channelType); + assert(baseModel !== undefined, "must have model"); + const channel = state.client.entryPoint.channels[op.channelType].find( + (v) => v.id === op.channelId, + ); + assert(channel !== undefined, "must have channel"); + await baseModel.reducer(covertLocalServerStateToDdsState(state, channel), op.op as any); +}; + +export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { + const buildChannelMap = (client: Client) => { + const channelMap = new Map(); + for (const value of Object.values(client.entryPoint.globalObjects).map((v) => + v.type === "stressDataObject" ? v : undefined, + )) { + if (value?.StressDataObject.attached) { + for (const channel of Object.values(value.StressDataObject.channels).flatMap( + (ca) => ca, + )) { + if (channel.isAttached()) { + channelMap.set(`${value.StressDataObject.id}/${channel.id}`, channel); + } + } + } + } + return channelMap; }; - - export const DDSModelOpReducer: AsyncReducer = async (state, op)=> { - const baseModel = ddsModelMap.get(op.channelType); - assert(baseModel !== undefined, "must have model"); - const channel = state.client.entryPoint.channels[op.channelType].find((v)=>v.id===op.channelId); - assert(channel !== undefined, "must have channel"); - await baseModel.reducer(covertLocalServerStateToDdsState(state, channel), - op.op as any, - ); - }; + const aMap = buildChannelMap(clientA); + const bMap = buildChannelMap(clientB); + assert(aMap.size === bMap.size, "channel maps should be the same size"); + for (const key of aMap.keys()) { + const aChannel = aMap.get(key); + const bChannel = bMap.get(key); + assert(aChannel !== undefined, "types must match"); + assert(aChannel.attributes.type === bChannel?.attributes.type, "types must match"); + const model = ddsModelMap.get(aChannel.attributes.type); + await model?.validateConsistency(createDDSClient(aChannel), createDDSClient(bChannel)); + } +}; diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index 56aaaa165d8f..8d221fa90e27 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -15,7 +15,7 @@ import type { FluidObject } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; - +import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; export class StressDataObject extends DataObject { get StressDataObject() { @@ -63,15 +63,14 @@ export class StressDataObject extends DataObject { return this.runtime.attachState === AttachState.Attached; } - public channels: Record = { - }; + public channels: Record = {}; protected async preInitialize(): Promise { const root = await this.getDefaultStressDataObject(); this._globalObjects = root._globalObjects; - const channels = this.channels[this.root.attributes.type] ??=[]; + const channels = (this.channels[this.root.attributes.type] ??= []); channels.push(this.root); setTimeout(() => { @@ -83,19 +82,21 @@ export class StressDataObject extends DataObject { }, 0); } - public uploadBlob(id: string, contents: string) { - void this.runtime - .uploadBlob(stringToBuffer(contents, "utf-8")) - .then( - (blobHandle) => (this._globalObjects[id] = { type: "newBlob", handle: blobHandle }), - ); + public uploadBlob(contents: string) { + void this.runtime.uploadBlob(stringToBuffer(contents, "utf-8")).then( + (blobHandle) => + (this._globalObjects[toFluidHandleInternal(blobHandle).absolutePath] = { + type: "newBlob", + handle: blobHandle, + }), + ); } - public createDataStore(id: string) { + public createDataStore() { void this.context.containerRuntime .createDataStore(stressDataObjectFactory.type) .then(async (dataStore) => { - this._globalObjects[id] = { + this._globalObjects[dataStore.entryPoint.absolutePath] = { type: "newDatastore", dataStore, handle: dataStore.entryPoint, @@ -119,7 +120,7 @@ class DefaultStressDataObject extends StressDataObject { } protected async preInitialize(): Promise { - const channels = this.channels[this.root.attributes.type] ??=[]; + const channels = (this.channels[this.root.attributes.type] ??= []); channels.push(this.root); this._globalObjects[this.id] = { type: "stressDataObject", diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index af5c6826c09c..7e3aa0ed0652 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -13,15 +13,17 @@ import { } from "@fluid-private/stochastic-test-utils"; import { type IFluidHandle } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; -import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; -import {ddsModelMap, DDSModelOpGenerator, type DDSModelOp} from "../ddsModels.js" import { - Client, + DDSModelOpGenerator, + DDSModelOpReducer, + validateConsistencyOfAllDDS, + type DDSModelOp, +} from "../ddsModels.js"; +import { createLocalServerStressSuite, LocalServerStressModel, - makeUnreachableCodePathProxy, type LocalServerStressState, } from "../localServerStressHarness"; @@ -48,40 +50,12 @@ const reducer = combineReducersAsync({ void entry.dataStore.trySetAlias(String.fromCodePoint(state.random.integer(0, 26) + 65)); }, createDataStore: async (state) => { - state.client.entryPoint.createDataStore(state.random.uuid4()); + state.client.entryPoint.createDataStore(); }, uploadBlob: async (state) => { - state.client.entryPoint.uploadBlob( - state.random.uuid4(), - state.random.string(state.random.integer(1, 246)), - ); - }, - DDSModelOp: async (state, op) => { - const baseModel = ddsModelMap.get(op.channelType); - assert(baseModel !== undefined, "must have model"); - const channel = state.client.entryPoint.channels[op.channelType].find((v)=>v.id===op.channelId); - assert(channel !== undefined, "must have channel"); - await baseModel.reducer( - { - clients: makeUnreachableCodePathProxy("clients"), - client: { - channel, - containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), - dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), - }, - containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), - isDetached: state.isDetached, - summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), - random: { - ...state.random, - handle: () => { - throw new Error("foo"); - }, - }, - }, - op.op as any, - ); + state.client.entryPoint.uploadBlob(state.random.string(state.random.integer(1, 246))); }, + DDSModelOp: DDSModelOpReducer, }); function makeGenerator(): AsyncGenerator { @@ -103,7 +77,6 @@ function makeGenerator(): AsyncGenerator( [ [ @@ -125,54 +98,12 @@ function makeGenerator(): AsyncGenerator { - const buildChannelMap = (client: Client) => { - const channelMap = new Map(); - for (const value of Object.values(client.entryPoint.globalObjects).map((v) => - v.type === "stressDataObject" ? v : undefined, - )) { - if (value?.StressDataObject.attached) { - for (const channel of Object.values(value.StressDataObject.channels).flatMap((ca)=>ca)) { - if (channel.isAttached()) { - channelMap.set(`${value.StressDataObject.id}/${channel.id}`, channel); - } - } - } - } - return channelMap; - }; - const aMap = buildChannelMap(clientA); - const bMap = buildChannelMap(clientB); - assert(aMap.size === bMap.size, "channel maps should be the same size"); - for (const key of aMap.keys()) { - const aChannel = aMap.get(key); - const bChannel = bMap.get(key); - assert(aChannel !== undefined, "types must match"); - assert(aChannel.attributes.type === bChannel?.attributes.type, "types must match"); - const model = ddsModelMap.get(aChannel.attributes.type); - await model?.validateConsistency( - { - channel: aChannel, - containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), - dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), - }, - { - channel: bChannel, - containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), - dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), - }, - ); - } -}; - describe("Local Server Stress", () => { const model: LocalServerStressModel = { workloadName: "default", generatorFactory: () => takeAsync(1000, makeGenerator()), - reducer: async (state, operation) => reducer(state, operation), - validateConsistency, + reducer, + validateConsistency: validateConsistencyOfAllDDS, }; createLocalServerStressSuite(model, { From 741dbf864825ba6891094a83712b890fff9a24a4 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 12:59:58 -0800 Subject: [PATCH 23/54] fix dir fuzz --- packages/dds/map/src/test/mocha/fuzzUtils.ts | 39 +++++++------------ .../src/ddsModels.ts | 12 ++++-- .../src/stressDataObject.ts | 10 ++--- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/packages/dds/map/src/test/mocha/fuzzUtils.ts b/packages/dds/map/src/test/mocha/fuzzUtils.ts index b1d269d3af07..042faac1bf79 100644 --- a/packages/dds/map/src/test/mocha/fuzzUtils.ts +++ b/packages/dds/map/src/test/mocha/fuzzUtils.ts @@ -15,21 +15,21 @@ import { createWeightedGenerator, takeAsync, } from "@fluid-private/stochastic-test-utils"; -import type { - Client, - DDSFuzzModel, - DDSFuzzTestState, -} from "@fluid-private/test-dds-utils"; +import type { Client, DDSFuzzModel, DDSFuzzTestState } from "@fluid-private/test-dds-utils"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; import { isObject } from "@fluidframework/core-utils/internal"; import type { Serializable } from "@fluidframework/datastore-definitions/internal"; import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; -import { DirectoryFactory, type IDirectory, type ISharedMap, MapFactory } from "../../index.js"; +import { + DirectoryFactory, + type IDirectory, + type ISharedMap, + MapFactory, +} from "../../index.js"; import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; - /** * Represents a map clear operation. */ @@ -155,8 +155,6 @@ export const baseMapModel: DDSFuzzModel = { validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), }; - - type DirFuzzTestState = DDSFuzzTestState; /** @@ -402,7 +400,8 @@ export function makeDirOperationGenerator( [ deleteSubDirectory, options.deleteSubDirWeight, - (state: DirFuzzTestState): boolean => (state.client.channel.countSubDirectory?.() ?? 0) > 0, + (state: DirFuzzTestState): boolean => + (state.client.channel.countSubDirectory?.() ?? 0) > 0, ], [setKey, options.setKeyWeight], [ @@ -447,7 +446,9 @@ function logCurrentState(clients: Client[], loggingInfo: Loggi * @param loggingInfo - The logging information. * @returns An asynchronous reducer for directory operations. */ -export function makeDirReducer(loggingInfo?: LoggingInfo): AsyncReducer { +export function makeDirReducer( + loggingInfo?: LoggingInfo, +): AsyncReducer { const withLogging = (baseReducer: AsyncReducer): AsyncReducer => async (state, operation) => { @@ -498,26 +499,12 @@ export function makeDirReducer(loggingInfo?: LoggingInfo): AsyncReducer = { workloadName: "default directory 1", - generatorFactory: () => takeAsync(100, makeDirOperationGenerator(dirOptions)), + generatorFactory: () => takeAsync(100, makeDirOperationGenerator(dirDefaultOptions)), reducer: makeDirReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), factory: new DirectoryFactory(), diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 431309f20307..063fe98a4557 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -21,6 +21,7 @@ import type { } from "@fluidframework/datastore-definitions/internal"; // eslint-disable-next-line import/no-internal-modules import { baseMapModel, baseDirModel } from "@fluidframework/map/internal/test"; +import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import { baseSharedStringModel, baseIntervalModel, @@ -111,12 +112,15 @@ const covertLocalServerStateToDdsState = ( random: { ...state.random, handle: () => { - const realHandle = state.random.pick( - Object.values(state.client.entryPoint.globalObjects) - .map((v) => v.handle) - .filter((v): v is IFluidHandle => v !== undefined), + const realHandle = toFluidHandleInternal( + state.random.pick( + Object.values(state.client.entryPoint.globalObjects) + .map((v) => v.handle) + .filter((v): v is IFluidHandle => v !== undefined), + ), ); return { + absolutePath: realHandle.absolutePath, get [fluidHandleSymbol]() { return realHandle[fluidHandleSymbol]; }, diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index 8d221fa90e27..af7997a3d01f 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -24,7 +24,7 @@ export class StressDataObject extends DataObject { protected _globalObjects: Record< string, - | { type: "newBlob"; handle: IFluidHandle } + | { type: "newBlob"; handle: IFluidHandle } | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } | { type: "stressDataObject"; @@ -37,7 +37,7 @@ export class StressDataObject extends DataObject { public get globalObjects(): Readonly< Record< string, - | { type: "newBlob"; handle: IFluidHandle } + | { type: "newBlob"; handle: IFluidHandle } | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } | { type: "stressDataObject"; @@ -84,10 +84,10 @@ export class StressDataObject extends DataObject { public uploadBlob(contents: string) { void this.runtime.uploadBlob(stringToBuffer(contents, "utf-8")).then( - (blobHandle) => - (this._globalObjects[toFluidHandleInternal(blobHandle).absolutePath] = { + (handle) => + (this._globalObjects[toFluidHandleInternal(handle).absolutePath] = { type: "newBlob", - handle: blobHandle, + handle, }), ); } From 547d066cb37bc274a952d01a25c92faf051d11ef Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 13:22:32 -0800 Subject: [PATCH 24/54] add create channel --- .../src/ddsModels.ts | 9 +- .../src/localServerStressHarness.ts | 7 +- .../src/stressDataObject.ts | 110 ++++++++++-------- .../src/test/localServerStress.spec.ts | 23 +++- 4 files changed, 95 insertions(+), 54 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 063fe98a4557..b41cd23a9ed8 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -113,11 +113,14 @@ const covertLocalServerStateToDdsState = ( ...state.random, handle: () => { const realHandle = toFluidHandleInternal( - state.random.pick( - Object.values(state.client.entryPoint.globalObjects) + state.random.pick([ + ...Object.values(state.client.entryPoint.channels) + .flatMap((ca) => ca) + .map((c) => c.handle), + ...Object.values(state.client.entryPoint.globalObjects) .map((v) => v.handle) .filter((v): v is IFluidHandle => v !== undefined), - ), + ]), ); return { absolutePath: realHandle.absolutePath, diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 1577cacc4fdf..0c1dc4763f90 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -40,7 +40,7 @@ import { createDetachedContainer, loadExistingContainer, } from "@fluidframework/container-loader/internal"; -import type { FluidObject } from "@fluidframework/core-interfaces"; +import type { FluidObject } from "@fluidframework/core-interfaces"; import { unreachableCase } from "@fluidframework/core-utils/internal"; import { createLocalResolverCreateNewRequest, @@ -55,7 +55,7 @@ import { LocalCodeLoader } from "@fluidframework/test-utils/internal"; import { FuzzTestMinimizer } from "./minification.js"; import type { MinimizationTransform } from "./minification.js"; -import {runtimeFactory, StressDataObject} from "./stressDataObject.js" +import { createRuntimeFactory, StressDataObject } from "./stressDataObject.js"; const isOperationType = ( type: O["type"], @@ -837,7 +837,6 @@ function makeFriendlyClientId(random: IRandom, index: number): string { return index < 26 ? String.fromCodePoint(index + 65) : random.uuid4(); } - /** * Runs the provided DDS fuzz model. All functionality is already assumed to be mixed in. * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to @@ -856,7 +855,7 @@ async function runTestForSeed( const codeDetails: IFluidCodeDetails = { package: "local-server-stress-tests", }; - const codeLoader = new LocalCodeLoader([[codeDetails, runtimeFactory]]); + const codeLoader = new LocalCodeLoader([[codeDetails, createRuntimeFactory()]]); const initialClient = await createDetachedClient( localDeltaConnectionServer, codeLoader, diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index af7997a3d01f..58348bfcc93d 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -12,12 +12,24 @@ import { import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; import type { FluidObject } from "@fluidframework/core-interfaces"; -import { assert } from "@fluidframework/core-utils/internal"; +import { assert, Lazy } from "@fluidframework/core-utils/internal"; import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; +import { ddsModelMap } from "./ddsModels"; + export class StressDataObject extends DataObject { + public static readonly factory = new Lazy( + () => + new DataObjectFactory( + "StressDataObject", + StressDataObject, + [...ddsModelMap.values()].map((v) => v.factory), + {}, + ), + ); + get StressDataObject() { return this; } @@ -92,9 +104,14 @@ export class StressDataObject extends DataObject { ); } + public createChannel(type: string) { + const channels = (this.channels[type] ??= []); + channels.push(this.runtime.createChannel(undefined, type)); + } + public createDataStore() { void this.context.containerRuntime - .createDataStore(stressDataObjectFactory.type) + .createDataStore(StressDataObject.factory.value.type) .then(async (dataStore) => { this._globalObjects[dataStore.entryPoint.absolutePath] = { type: "newDatastore", @@ -105,13 +122,6 @@ export class StressDataObject extends DataObject { } } -const stressDataObjectFactory = new DataObjectFactory( - "StressDataObject", - StressDataObject, - undefined, - {}, -); - class DefaultStressDataObject extends StressDataObject { public static readonly alias = "default"; @@ -131,41 +141,49 @@ class DefaultStressDataObject extends StressDataObject { } } -export const defaultStressDataObjectFactory = new DataObjectFactory( - "DefaultStressDataObject", - DefaultStressDataObject, - undefined, - {}, -); - -export const runtimeFactory: IRuntimeFactory = { - get IRuntimeFactory() { - return this; - }, - instantiateRuntime: async (context, existing) => { - return loadContainerRuntime({ - context, - existing, - registryEntries: [ - [defaultStressDataObjectFactory.type, Promise.resolve(defaultStressDataObjectFactory)], - [stressDataObjectFactory.type, Promise.resolve(stressDataObjectFactory)], - ], - provideEntryPoint: async (rt) => { - const maybeDefault = await rt.getAliasedDataStoreEntryPoint( - DefaultStressDataObject.alias, - ); - if (maybeDefault === undefined) { - const ds = await rt.createDataStore(defaultStressDataObjectFactory.type); - await ds.trySetAlias(DefaultStressDataObject.alias); - } - const aliasedDefault = await rt.getAliasedDataStoreEntryPoint( - DefaultStressDataObject.alias, - ); - assert(aliasedDefault !== undefined, "default must exist"); - - const maybe: FluidObject | undefined = await aliasedDefault.get(); - return maybe; - }, - }); - }, +export const createRuntimeFactory = (): IRuntimeFactory => { + const defaultStressDataObjectFactory = new DataObjectFactory( + "DefaultStressDataObject", + DefaultStressDataObject, + [...ddsModelMap.values()].map((v) => v.factory), + {}, + ); + + return { + get IRuntimeFactory() { + return this; + }, + instantiateRuntime: async (context, existing) => { + return loadContainerRuntime({ + context, + existing, + registryEntries: [ + [ + defaultStressDataObjectFactory.type, + Promise.resolve(defaultStressDataObjectFactory), + ], + [ + StressDataObject.factory.value.type, + Promise.resolve(StressDataObject.factory.value), + ], + ], + provideEntryPoint: async (rt) => { + const maybeDefault = await rt.getAliasedDataStoreEntryPoint( + DefaultStressDataObject.alias, + ); + if (maybeDefault === undefined) { + const ds = await rt.createDataStore(defaultStressDataObjectFactory.type); + await ds.trySetAlias(DefaultStressDataObject.alias); + } + const aliasedDefault = await rt.getAliasedDataStoreEntryPoint( + DefaultStressDataObject.alias, + ); + assert(aliasedDefault !== undefined, "default must exist"); + + const maybe: FluidObject | undefined = await aliasedDefault.get(); + return maybe; + }, + }); + }, + }; }; diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 7e3aa0ed0652..dd854b29f376 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -16,6 +16,7 @@ import { assert } from "@fluidframework/core-utils/internal"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { + ddsModelMap, DDSModelOpGenerator, DDSModelOpReducer, validateConsistencyOfAllDDS, @@ -40,7 +41,17 @@ interface CreateDataStore { type: "createDataStore"; } -type StressOperations = UploadBlob | AliasDataStore | CreateDataStore | DDSModelOp; +interface CreateChannel { + type: "createChannel"; + channelType: string; +} + +type StressOperations = + | UploadBlob + | AliasDataStore + | CreateDataStore + | CreateChannel + | DDSModelOp; const reducer = combineReducersAsync({ aliasDataStore: async (state, op) => { @@ -52,6 +63,9 @@ const reducer = combineReducersAsync({ createDataStore: async (state) => { state.client.entryPoint.createDataStore(); }, + createChannel: async (state, op) => { + state.client.entryPoint.createChannel(op.channelType); + }, uploadBlob: async (state) => { state.client.entryPoint.uploadBlob(state.random.string(state.random.integer(1, 246))); }, @@ -89,6 +103,13 @@ function makeGenerator(): AsyncGenerator ({ + type: "createChannel", + channelType: state.random.pick([...ddsModelMap.keys()]), + }), + 1, + ], [DDSModelOpGenerator, 2], ], ); From 1e2920d831618575efd1abd8843c2b22b47154ae Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 13:53:41 -0800 Subject: [PATCH 25/54] child datastores and disposal --- .../src/ddsModels.ts | 7 +++-- .../src/localServerStressHarness.ts | 4 +-- .../src/stressDataObject.ts | 30 +++++++++++-------- .../src/test/localServerStress.spec.ts | 15 +++++----- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index b41cd23a9ed8..5da57992ed81 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -194,9 +194,10 @@ export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Clie for (const key of aMap.keys()) { const aChannel = aMap.get(key); const bChannel = bMap.get(key); - assert(aChannel !== undefined, "types must match"); - assert(aChannel.attributes.type === bChannel?.attributes.type, "types must match"); + assert(aChannel !== undefined, "channel must exist"); + assert(aChannel.attributes.type === bChannel?.attributes.type, "channel types must match"); const model = ddsModelMap.get(aChannel.attributes.type); - await model?.validateConsistency(createDDSClient(aChannel), createDDSClient(bChannel)); + assert(model !== undefined, "model must exist"); + await model.validateConsistency(createDDSClient(aChannel), createDDSClient(bChannel)); } }; diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 0c1dc4763f90..8895cda7d3f5 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -847,7 +847,7 @@ async function runTestForSeed( options: Omit, seed: number, saveInfo?: SaveInfo, -): Promise { +): Promise { const random = makeRandom(seed); const startDetached = options.detachedStartOptions.numOpsBeforeAttach !== 0; @@ -911,7 +911,7 @@ async function runTestForSeed( options.emitter.emit("testEnd", finalState); - return finalState; + finalState.clients.forEach((c) => c.container.dispose()); } function runTest( diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index 58348bfcc93d..e20108580acd 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -12,7 +12,7 @@ import { import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; import type { FluidObject } from "@fluidframework/core-interfaces"; -import { assert, Lazy } from "@fluidframework/core-utils/internal"; +import { assert, Lazy, LazyPromise } from "@fluidframework/core-utils/internal"; import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; @@ -20,15 +20,16 @@ import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import { ddsModelMap } from "./ddsModels"; export class StressDataObject extends DataObject { - public static readonly factory = new Lazy( - () => - new DataObjectFactory( - "StressDataObject", - StressDataObject, - [...ddsModelMap.values()].map((v) => v.factory), - {}, - ), - ); + public static readonly factory = new Lazy(() => { + const factory = new DataObjectFactory( + "StressDataObject", + StressDataObject, + [...ddsModelMap.values()].map((v) => v.factory), + {}, + [["StressDataObject", new LazyPromise(() => factory)]], + ); + return factory; + }); get StressDataObject() { return this; @@ -109,9 +110,13 @@ export class StressDataObject extends DataObject { channels.push(this.runtime.createChannel(undefined, type)); } - public createDataStore() { + public createDataStore(asChild: boolean) { void this.context.containerRuntime - .createDataStore(StressDataObject.factory.value.type) + .createDataStore( + asChild + ? [...this.context.packagePath, StressDataObject.factory.value.type] + : StressDataObject.factory.value.type, + ) .then(async (dataStore) => { this._globalObjects[dataStore.entryPoint.absolutePath] = { type: "newDatastore", @@ -147,6 +152,7 @@ export const createRuntimeFactory = (): IRuntimeFactory => { DefaultStressDataObject, [...ddsModelMap.values()].map((v) => v.factory), {}, + [[StressDataObject.factory.value.type, StressDataObject.factory.value]], ); return { diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index dd854b29f376..cd40575e0492 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -39,6 +39,7 @@ interface AliasDataStore { } interface CreateDataStore { type: "createDataStore"; + asChild: boolean; } interface CreateChannel { @@ -60,14 +61,14 @@ const reducer = combineReducersAsync({ void entry.dataStore.trySetAlias(String.fromCodePoint(state.random.integer(0, 26) + 65)); }, - createDataStore: async (state) => { - state.client.entryPoint.createDataStore(); + createDataStore: async (state, op) => { + state.client.entryPoint.createDataStore(op.asChild); }, createChannel: async (state, op) => { state.client.entryPoint.createChannel(op.channelType); }, uploadBlob: async (state) => { - state.client.entryPoint.uploadBlob(state.random.string(state.random.integer(1, 246))); + state.client.entryPoint.uploadBlob(state.random.string(state.random.integer(1, 16))); }, DDSModelOp: DDSModelOpReducer, }); @@ -101,16 +102,16 @@ function makeGenerator(): AsyncGenerator v.type === "newDatastore", ), ], - [{ type: "createDataStore" }, 1], - [{ type: "uploadBlob" }, 1], + [async (state) => ({ type: "createDataStore", asChild: state.random.bool() }), 2], + [{ type: "uploadBlob" }, 2], [ async (state) => ({ type: "createChannel", channelType: state.random.pick([...ddsModelMap.keys()]), }), - 1, + 3, ], - [DDSModelOpGenerator, 2], + [DDSModelOpGenerator, 4], ], ); From 3a926c89188e4863725de2f47e1fb0901625a76d Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 14:22:09 -0800 Subject: [PATCH 26/54] remove client --- .../src/localServerStressHarness.ts | 95 ++++++++++++------- .../src/stressDataObject.ts | 8 ++ 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 8895cda7d3f5..291740c8f6a4 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -74,7 +74,7 @@ export interface Client { export interface LocalServerStressState extends BaseFuzzTestState { localDeltaConnectionServer: ILocalDeltaConnectionServer; codeLoader: ICodeDetailsLoader; - containerUrl?: string; + containerUrl: string | undefined; random: IRandom; clients: Client[]; client: Client; @@ -111,6 +111,14 @@ interface AddClient { url: string; } +/** + * @internal + */ +interface RemoveClient { + type: "removeClient"; + id: string; +} + /** * @internal */ @@ -435,45 +443,58 @@ const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to * expose at the package level if we want to expose some of the harness's building blocks. */ -function mixinNewClient< +function mixinAddRemoveClient< TOperation extends BaseOperation, TState extends LocalServerStressState, >( model: LocalServerStressModel, options: LocalServerStressOptions, -): LocalServerStressModel { - const isClientAddOp = (op: TOperation | AddClient): op is AddClient => - op.type === "addClient"; +): LocalServerStressModel { + const generatorFactory: () => AsyncGenerator = + () => { + const baseGenerator = model.generatorFactory(); + return async ( + state: TState, + ): Promise => { + const baseOp = baseGenerator(state); + const { clients, random, isDetached, containerUrl } = state; + if ( + containerUrl !== undefined && + options.clientJoinOptions !== undefined && + !isDetached && + random.bool(options.clientJoinOptions.clientAddProbability) + ) { + if (clients.length > options.numberOfClients && random.bool()) { + return { + type: "removeClient", + id: random.pick(clients).id, + } satisfies RemoveClient; + } - const generatorFactory: () => AsyncGenerator = () => { - const baseGenerator = model.generatorFactory(); - return async (state: TState): Promise => { - const baseOp = baseGenerator(state); - const { clients, random, isDetached, containerUrl } = state; - if ( - containerUrl !== undefined && - options.clientJoinOptions !== undefined && - clients.length < options.clientJoinOptions.maxNumberOfClients && - !isDetached && - random.bool(options.clientJoinOptions.clientAddProbability) - ) { - return { - type: "addClient", - url: containerUrl, - id: makeFriendlyClientId(random, clients.length), - } satisfies AddClient; - } - return baseOp; + if (clients.length < options.clientJoinOptions.maxNumberOfClients) { + return { + type: "addClient", + url: containerUrl, + id: makeFriendlyClientId(random, clients.length), + } satisfies AddClient; + } + } + return baseOp; + }; }; - }; - const minimizationTransforms: MinimizationTransform[] = + const minimizationTransforms: MinimizationTransform< + TOperation | AddClient | RemoveClient + >[] = (model.minimizationTransforms as - | MinimizationTransform[] + | MinimizationTransform[] | undefined) ?? []; - const reducer: AsyncReducer = async (state, op) => { - if (isClientAddOp(op)) { + const reducer: AsyncReducer = async ( + state, + op, + ) => { + if (isOperationType("addClient", op)) { const newClient = await loadClient( state.localDeltaConnectionServer, state.codeLoader, @@ -483,6 +504,14 @@ function mixinNewClient< state.clients.push(newClient); return state; } + if (isOperationType("removeClient", op)) { + const removed = state.clients.splice( + state.clients.findIndex((c) => c.id === op.id), + 1, + ); + removed[0].container.dispose(); + return state; + } return model.reducer(state, op); }; @@ -863,9 +892,10 @@ async function runTestForSeed( startDetached ? makeFriendlyClientId(random, 0) : "original", ); const clients: Client[] = [initialClient]; + let containerUrl: string | undefined; if (!startDetached) { await initialClient.container.attach(createLocalResolverCreateNewRequest("stress")); - const url = await initialClient.container.getAbsoluteUrl(""); + const url = (containerUrl = await initialClient.container.getAbsoluteUrl("")); assert(url !== undefined, "attached container must have url"); clients.push( ...(await Promise.all( @@ -888,6 +918,7 @@ async function runTestForSeed( random, client: makeUnreachableCodePathProxy("client"), isDetached: startDetached, + containerUrl, }; options.emitter.emit("testStart", initialState); @@ -1171,11 +1202,11 @@ const getFullModel = ( ddsModel: LocalServerStressModel, options: LocalServerStressOptions, ): LocalServerStressModel< - TOperation | AddClient | Attach | Synchronize | ChangeConnectionState + TOperation | AddClient | RemoveClient | Attach | Synchronize | ChangeConnectionState > => mixinAttach( mixinSynchronization( - mixinNewClient( + mixinAddRemoveClient( mixinClientSelection(mixinReconnect(ddsModel, options), options), options, ), diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index e20108580acd..0ad3255da5ae 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -163,6 +163,14 @@ export const createRuntimeFactory = (): IRuntimeFactory => { return loadContainerRuntime({ context, existing, + runtimeOptions: { + summaryOptions: { + summaryConfigOverrides: { + maxOps: 3, + initialSummarizerDelayMs: 0, + } as any, + }, + }, registryEntries: [ [ defaultStressDataObjectFactory.type, From 8356babefb9d928bfb23f13549df85f28a6a7dd5 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 16:52:03 -0800 Subject: [PATCH 27/54] format fixes --- packages/dds/map/src/test/index.ts | 3 +- .../src/test/mocha/directoryFuzzTests.spec.ts | 28 +++++++++++-------- packages/dds/map/src/test/mocha/index.ts | 3 +- .../dds/map/src/test/mocha/map.fuzz.spec.ts | 5 +--- .../dds/sequence/src/test/fuzz/fuzzUtils.ts | 2 +- .../test/local-server-stress-tests/.gitignore | 2 -- .../local-server-stress-tests/biome.jsonc | 5 +++- .../local-server-stress-tests/package.json | 22 +++++++-------- .../src/test/localServerStress.spec.ts | 9 +++--- 9 files changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/dds/map/src/test/index.ts b/packages/dds/map/src/test/index.ts index b734a136483f..7fee42bd106e 100644 --- a/packages/dds/map/src/test/index.ts +++ b/packages/dds/map/src/test/index.ts @@ -2,4 +2,5 @@ * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. */ -export { baseDirModel, baseMapModel } from "./mocha/index.js"; + +export { baseDirModel, baseMapModel } from "./mocha/index.js"; diff --git a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts index bf4022de5e99..9e16725f86ec 100644 --- a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts +++ b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts @@ -5,25 +5,28 @@ import * as dirPath from "node:path"; -import { - takeAsync, -} from "@fluid-private/stochastic-test-utils"; -import { - type DDSFuzzModel, - createDDSFuzzSuite, -} from "@fluid-private/test-dds-utils"; +import { takeAsync } from "@fluid-private/stochastic-test-utils"; +import { type DDSFuzzModel, createDDSFuzzSuite } from "@fluid-private/test-dds-utils"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; import { DirectoryFactory } from "../../index.js"; import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; import { _dirname } from "./dirname.cjs"; -import { baseDirModel, dirDefaultOptions, makeDirOperationGenerator, makeDirReducer, type DirOperation } from "./fuzzUtils.js"; +import { + baseDirModel, + dirDefaultOptions, + makeDirOperationGenerator, + makeDirReducer, + type DirOperation, +} from "./fuzzUtils.js"; describe("SharedDirectory fuzz Create/Delete concentrated", () => { - createDDSFuzzSuite(baseDirModel, { - validationStrategy: { type: "fixedInterval", interval: dirDefaultOptions.validateInterval }, + validationStrategy: { + type: "fixedInterval", + interval: dirDefaultOptions.validateInterval, + }, reconnectProbability: 0.15, numberOfClients: 3, // We prevent handles from being generated on the creation/deletion tests since the set operations are disabled. @@ -80,7 +83,10 @@ describe("SharedDirectory fuzz", () => { }; createDDSFuzzSuite(model, { - validationStrategy: { type: "fixedInterval", interval: dirDefaultOptions.validateInterval }, + validationStrategy: { + type: "fixedInterval", + interval: dirDefaultOptions.validateInterval, + }, reconnectProbability: 0.15, numberOfClients: 3, clientJoinOptions: { diff --git a/packages/dds/map/src/test/mocha/index.ts b/packages/dds/map/src/test/mocha/index.ts index 110e48e4a35e..fa6033d61155 100644 --- a/packages/dds/map/src/test/mocha/index.ts +++ b/packages/dds/map/src/test/mocha/index.ts @@ -2,4 +2,5 @@ * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. */ -export { baseDirModel,baseMapModel } from "./fuzzUtils.js"; + +export { baseDirModel, baseMapModel } from "./fuzzUtils.js"; diff --git a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts index 260e8731c100..dad62a8655a7 100644 --- a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts +++ b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts @@ -5,12 +5,9 @@ import * as path from "node:path"; -import { - createDDSFuzzSuite, -} from "@fluid-private/test-dds-utils"; +import { createDDSFuzzSuite } from "@fluid-private/test-dds-utils"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; - import { _dirname } from "./dirname.cjs"; import { baseMapModel } from "./fuzzUtils.js"; diff --git a/packages/dds/sequence/src/test/fuzz/fuzzUtils.ts b/packages/dds/sequence/src/test/fuzz/fuzzUtils.ts index d0b0164165bf..3669627dc11f 100644 --- a/packages/dds/sequence/src/test/fuzz/fuzzUtils.ts +++ b/packages/dds/sequence/src/test/fuzz/fuzzUtils.ts @@ -689,7 +689,7 @@ export function makeSharedStringOperationGenerator( ]); } -export const baseSharedStringModel= { +export const baseSharedStringModel = { ...baseModel, generatorFactory: () => takeAsync( diff --git a/packages/test/local-server-stress-tests/.gitignore b/packages/test/local-server-stress-tests/.gitignore index e4dc83cb7a57..ee26a5e7bdbf 100644 --- a/packages/test/local-server-stress-tests/.gitignore +++ b/packages/test/local-server-stress-tests/.gitignore @@ -50,5 +50,3 @@ nyc # Generated modules intel_modules/ temp_modules/ - -results diff --git a/packages/test/local-server-stress-tests/biome.jsonc b/packages/test/local-server-stress-tests/biome.jsonc index 4b65e1c0aea2..11302bab0e3e 100644 --- a/packages/test/local-server-stress-tests/biome.jsonc +++ b/packages/test/local-server-stress-tests/biome.jsonc @@ -1,4 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "extends": ["../../../biome.jsonc"] + "extends": ["../../../biome.jsonc"], + "files": { + "ignore": ["results/**"] + } } diff --git a/packages/test/local-server-stress-tests/package.json b/packages/test/local-server-stress-tests/package.json index 88684fdb8839..f768c745c64d 100644 --- a/packages/test/local-server-stress-tests/package.json +++ b/packages/test/local-server-stress-tests/package.json @@ -7,7 +7,7 @@ "repository": { "type": "git", "url": "https://github.com/microsoft/FluidFramework.git", - "directory": "packages/test/local-server-tests" + "directory": "packages/test/local-server-stress-tests" }, "license": "MIT", "author": "Microsoft and contributors", @@ -56,35 +56,35 @@ "temp-directory": "nyc/.nyc_output" }, "dependencies": { - "@fluid-private/stochastic-test-utils": "workspace:~", - "@fluid-internal/client-utils": "workspace:~", - "@fluidframework/aqueduct": "workspace:~", - "@fluidframework/container-loader": "workspace:~", - "@fluidframework/core-utils": "workspace:~", - "@fluidframework/local-driver": "workspace:~", - "@fluidframework/server-local-server": "^5.0.0", - "@fluidframework/test-utils": "workspace:~", "@fluid-experimental/tree": "workspace:~", + "@fluid-internal/client-utils": "workspace:~", "@fluid-internal/mocha-test-setup": "workspace:~", + "@fluid-private/stochastic-test-utils": "workspace:~", + "@fluid-private/test-dds-utils": "workspace:~", + "@fluidframework/aqueduct": "workspace:~", "@fluidframework/build-common": "^2.0.3", "@fluidframework/build-tools": "^0.51.0", "@fluidframework/container-definitions": "workspace:~", + "@fluidframework/container-loader": "workspace:~", "@fluidframework/container-runtime": "workspace:~", "@fluidframework/container-runtime-definitions": "workspace:~", "@fluidframework/core-interfaces": "workspace:~", + "@fluidframework/core-utils": "workspace:~", "@fluidframework/datastore": "workspace:~", "@fluidframework/datastore-definitions": "workspace:~", "@fluidframework/driver-definitions": "workspace:~", "@fluidframework/driver-utils": "workspace:~", - "@fluidframework/eslint-config-fluid": "^5.6.0", + "@fluidframework/eslint-config-fluid": "^5.7.3", "@fluidframework/id-compressor": "workspace:~", + "@fluidframework/local-driver": "workspace:~", "@fluidframework/map": "workspace:~", "@fluidframework/runtime-definitions": "workspace:~", "@fluidframework/runtime-utils": "workspace:~", "@fluidframework/sequence": "workspace:~", + "@fluidframework/server-local-server": "^5.0.0", "@fluidframework/telemetry-utils": "workspace:~", + "@fluidframework/test-utils": "workspace:~", "@fluidframework/tree": "workspace:~", - "@fluid-private/test-dds-utils": "workspace:~", "uuid": "^9.0.0" }, "devDependencies": { diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index cd40575e0492..80656775227d 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -117,13 +117,13 @@ function makeGenerator(): AsyncGenerator syncGenerator(state); } -export const saveFailures = { directory: path.join(_dirname, "../../results") }; -export const saveSuccesses = { directory: path.join(_dirname, "../../results") }; +export const saveFailures = { directory: path.join(_dirname, "../../src/test/results") }; +export const saveSuccesses = { directory: path.join(_dirname, "../../rc/test/results") }; describe("Local Server Stress", () => { const model: LocalServerStressModel = { workloadName: "default", - generatorFactory: () => takeAsync(1000, makeGenerator()), + generatorFactory: () => takeAsync(100, makeGenerator()), reducer, validateConsistency: validateConsistencyOfAllDDS, }; @@ -138,7 +138,8 @@ describe("Local Server Stress", () => { reconnectProbability: 0.1, skipMinimization: true, // Uncomment to replay a particular seed. + // replay: 24, saveFailures, - saveSuccesses, + // saveSuccesses, }); }); From 4d0803676784edeab7484916f6fef038ee30a564 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Thu, 30 Jan 2025 17:35:25 -0800 Subject: [PATCH 28/54] allow id generation override --- .../src/channelCollection.ts | 3 +-- .../container-runtime/src/containerRuntime.ts | 20 +++++++++------ .../runtime/datastore/src/dataStoreRuntime.ts | 2 +- .../src/stressDataObject.ts | 25 ++++++++++++------- .../src/test/localServerStress.spec.ts | 4 +-- .../test-service-load/src/optionsMatrix.ts | 1 + pnpm-lock.yaml | 2 +- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/packages/runtime/container-runtime/src/channelCollection.ts b/packages/runtime/container-runtime/src/channelCollection.ts index 80444810e660..05b84751ac5f 100644 --- a/packages/runtime/container-runtime/src/channelCollection.ts +++ b/packages/runtime/container-runtime/src/channelCollection.ts @@ -73,7 +73,6 @@ import { tagCodeArtifacts, type ITelemetryPropertiesExt, } from "@fluidframework/telemetry-utils/internal"; -import { v4 as uuid } from "uuid"; import { // eslint-disable-next-line import/no-deprecated @@ -644,7 +643,7 @@ export class ChannelCollection implements IFluidDataStoreChannel, IDisposable { } return id; } - return uuid(); + return `${this.parentContext.containerRuntime.generateDocumentUniqueId()}`; } public createDetachedDataStore( diff --git a/packages/runtime/container-runtime/src/containerRuntime.ts b/packages/runtime/container-runtime/src/containerRuntime.ts index c0c0f75ea6a7..b4531fe8c1ed 100644 --- a/packages/runtime/container-runtime/src/containerRuntime.ts +++ b/packages/runtime/container-runtime/src/containerRuntime.ts @@ -582,6 +582,8 @@ export interface IContainerRuntimeOptionsInternal extends IContainerRuntimeOptio * In that case, batched messages will be sent individually (but still all at the same time). */ readonly enableGroupedBatching?: boolean; + + readonly defaultGlobalIdGenerator?: () => string; } /** @@ -1001,6 +1003,7 @@ export class ContainerRuntime chunkSizeInBytes = defaultChunkSizeInBytes, enableGroupedBatching = true, explicitSchemaControl = false, + defaultGlobalIdGenerator = () => uuid(), }: IContainerRuntimeOptionsInternal = runtimeOptions; const registry = new FluidDataStoreRegistry(registryEntries); @@ -1204,6 +1207,7 @@ export class ContainerRuntime enableRuntimeIdCompressor: enableRuntimeIdCompressor as "on" | "delayed", enableGroupedBatching, explicitSchemaControl, + defaultGlobalIdGenerator, }; const runtime = new containerRuntimeCtor( @@ -1249,6 +1253,8 @@ export class ContainerRuntime return runtime; } + private readonly defaultGlobalIdGenerator: () => string; + public readonly options: Record; private imminentClosure: boolean = false; @@ -1351,7 +1357,7 @@ export class ContainerRuntime * {@inheritDoc @fluidframework/runtime-definitions#IContainerRuntimeBase.generateDocumentUniqueId} */ public generateDocumentUniqueId(): string | number { - return this._idCompressor?.generateDocumentUniqueId() ?? uuid(); + return this._idCompressor?.generateDocumentUniqueId() ?? this.defaultGlobalIdGenerator(); } public get IFluidHandleContext(): IFluidHandleContext { @@ -1550,7 +1556,7 @@ export class ContainerRuntime electedSummarizerData: ISerializedElection | undefined, chunks: [string, string[]][], dataStoreAliasMap: [string, string][], - baseRuntimeOptions: Readonly>, + runtimeOptions: Readonly>, private readonly containerScope: FluidObject, // Create a custom ITelemetryBaseLogger to output telemetry events. public readonly baseLogger: ITelemetryBaseLogger, @@ -1571,7 +1577,7 @@ export class ContainerRuntime // the defaults ...DefaultSummaryConfiguration, // the runtime configuration overrides - ...baseRuntimeOptions.summaryOptions?.summaryConfigOverrides, + ...runtimeOptions.summaryOptions?.summaryConfigOverrides, }, recentBatchInfo?: [number, string][], ) { @@ -1604,10 +1610,8 @@ export class ContainerRuntime validateLoaderCompatibility(maybeLoaderCompatDetails.ILayerCompatDetails, this.disposeFn); // Backfill in defaults for the internal runtimeOptions, since they may not be present on the provided runtimeOptions object - const runtimeOptions = { - flushMode: defaultFlushMode, - ...baseRuntimeOptions, - }; + this.defaultGlobalIdGenerator = runtimeOptions.defaultGlobalIdGenerator; + this.mc = createChildMonitoringContext({ logger: this.baseLogger, namespace: "ContainerRuntime", @@ -2144,7 +2148,7 @@ export class ContainerRuntime summaryFormatVersion: metadata?.summaryFormatVersion, disableIsolatedChannels: metadata?.disableIsolatedChannels, gcVersion: metadata?.gcFeature, - options: JSON.stringify(baseRuntimeOptions), + options: JSON.stringify(runtimeOptions), idCompressorModeMetadata: metadata?.documentSchema?.runtime?.idCompressorMode, idCompressorMode: this.sessionSchema.idCompressorMode, sessionRuntimeSchema: JSON.stringify(this.sessionSchema), diff --git a/packages/runtime/datastore/src/dataStoreRuntime.ts b/packages/runtime/datastore/src/dataStoreRuntime.ts index 8ded05a42811..b9a58d21bfd1 100644 --- a/packages/runtime/datastore/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore/src/dataStoreRuntime.ts @@ -481,7 +481,7 @@ export class FluidDataStoreRuntime id = typeof res === "number" ? encodeCompactIdToString(2 * res + 1, "_") : res; } } else { - id = uuid(); + id = `${this.dataStoreContext.containerRuntime.generateDocumentUniqueId()}`; } assert(!id.includes("/"), 0x8fc /* slash */); } diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index 0ad3255da5ae..af9a6ac34581 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -9,7 +9,10 @@ import { AttachState, type IRuntimeFactory, } from "@fluidframework/container-definitions/internal"; -import { loadContainerRuntime } from "@fluidframework/container-runtime/internal"; +import { + loadContainerRuntime, + type IContainerRuntimeOptionsInternal, +} from "@fluidframework/container-runtime/internal"; import type { IFluidHandle } from "@fluidframework/core-interfaces"; import type { FluidObject } from "@fluidframework/core-interfaces"; import { assert, Lazy, LazyPromise } from "@fluidframework/core-utils/internal"; @@ -155,6 +158,17 @@ export const createRuntimeFactory = (): IRuntimeFactory => { [[StressDataObject.factory.value.type, StressDataObject.factory.value]], ); + let id = 0; + const runtimeOptions: IContainerRuntimeOptionsInternal = { + summaryOptions: { + summaryConfigOverrides: { + maxOps: 3, + initialSummarizerDelayMs: 0, + } as any, + }, + defaultGlobalIdGenerator: () => `${id++}`, + }; + return { get IRuntimeFactory() { return this; @@ -163,14 +177,7 @@ export const createRuntimeFactory = (): IRuntimeFactory => { return loadContainerRuntime({ context, existing, - runtimeOptions: { - summaryOptions: { - summaryConfigOverrides: { - maxOps: 3, - initialSummarizerDelayMs: 0, - } as any, - }, - }, + runtimeOptions, registryEntries: [ [ defaultStressDataObjectFactory.type, diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 80656775227d..1d344c1c5126 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -118,7 +118,7 @@ function makeGenerator(): AsyncGenerator syncGenerator(state); } export const saveFailures = { directory: path.join(_dirname, "../../src/test/results") }; -export const saveSuccesses = { directory: path.join(_dirname, "../../rc/test/results") }; +export const saveSuccesses = { directory: path.join(_dirname, "../../src/test/results") }; describe("Local Server Stress", () => { const model: LocalServerStressModel = { @@ -140,6 +140,6 @@ describe("Local Server Stress", () => { // Uncomment to replay a particular seed. // replay: 24, saveFailures, - // saveSuccesses, + saveSuccesses, }); }); diff --git a/packages/test/test-service-load/src/optionsMatrix.ts b/packages/test/test-service-load/src/optionsMatrix.ts index a12118ad2908..7c46a178b61c 100644 --- a/packages/test/test-service-load/src/optionsMatrix.ts +++ b/packages/test/test-service-load/src/optionsMatrix.ts @@ -115,6 +115,7 @@ export function generateRuntimeOptions( enableRuntimeIdCompressor: ["on", undefined, "delayed"], enableGroupedBatching: [true, false], explicitSchemaControl: [true, false], + defaultGlobalIdGenerator: [], }; const pairwiseOptions = generatePairwiseOptions( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bf9a81ddf72..d37de6ae5971 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13789,7 +13789,7 @@ importers: specifier: workspace:~ version: link:../../loader/driver-utils '@fluidframework/eslint-config-fluid': - specifier: ^5.6.0 + specifier: ^5.7.3 version: 5.7.3(eslint@8.55.0)(typescript@5.4.5) '@fluidframework/id-compressor': specifier: workspace:~ From 857f4585c6708c42e3689c44cb80b6470cb8542c Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Fri, 31 Jan 2025 09:48:29 -0800 Subject: [PATCH 29/54] undo id changes --- .../src/channelCollection.ts | 3 ++- .../container-runtime/src/containerRuntime.ts | 20 ++++++++----------- .../runtime/datastore/src/dataStoreRuntime.ts | 2 +- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/runtime/container-runtime/src/channelCollection.ts b/packages/runtime/container-runtime/src/channelCollection.ts index 02913862eab2..c01ec34895fb 100644 --- a/packages/runtime/container-runtime/src/channelCollection.ts +++ b/packages/runtime/container-runtime/src/channelCollection.ts @@ -73,6 +73,7 @@ import { tagCodeArtifacts, type ITelemetryPropertiesExt, } from "@fluidframework/telemetry-utils/internal"; +import { v4 as uuid } from "uuid"; import { // eslint-disable-next-line import/no-deprecated @@ -643,7 +644,7 @@ export class ChannelCollection implements IFluidDataStoreChannel, IDisposable { } return id; } - return `${this.parentContext.containerRuntime.generateDocumentUniqueId()}`; + return uuid(); } public createDetachedDataStore( diff --git a/packages/runtime/container-runtime/src/containerRuntime.ts b/packages/runtime/container-runtime/src/containerRuntime.ts index 7e2e2e485b83..00c60e4592e6 100644 --- a/packages/runtime/container-runtime/src/containerRuntime.ts +++ b/packages/runtime/container-runtime/src/containerRuntime.ts @@ -582,8 +582,6 @@ export interface IContainerRuntimeOptionsInternal extends IContainerRuntimeOptio * In that case, batched messages will be sent individually (but still all at the same time). */ readonly enableGroupedBatching?: boolean; - - readonly defaultGlobalIdGenerator?: () => string; } /** @@ -1003,7 +1001,6 @@ export class ContainerRuntime chunkSizeInBytes = defaultChunkSizeInBytes, enableGroupedBatching = true, explicitSchemaControl = false, - defaultGlobalIdGenerator = () => uuid(), }: IContainerRuntimeOptionsInternal = runtimeOptions; const registry = new FluidDataStoreRegistry(registryEntries); @@ -1207,7 +1204,6 @@ export class ContainerRuntime enableRuntimeIdCompressor: enableRuntimeIdCompressor as "on" | "delayed", enableGroupedBatching, explicitSchemaControl, - defaultGlobalIdGenerator, }; const runtime = new containerRuntimeCtor( @@ -1253,8 +1249,6 @@ export class ContainerRuntime return runtime; } - private readonly defaultGlobalIdGenerator: () => string; - public readonly options: Record; private imminentClosure: boolean = false; @@ -1357,7 +1351,7 @@ export class ContainerRuntime * {@inheritDoc @fluidframework/runtime-definitions#IContainerRuntimeBase.generateDocumentUniqueId} */ public generateDocumentUniqueId(): string | number { - return this._idCompressor?.generateDocumentUniqueId() ?? this.defaultGlobalIdGenerator(); + return this._idCompressor?.generateDocumentUniqueId() ?? uuid(); } public get IFluidHandleContext(): IFluidHandleContext { @@ -1556,7 +1550,7 @@ export class ContainerRuntime electedSummarizerData: ISerializedElection | undefined, chunks: [string, string[]][], dataStoreAliasMap: [string, string][], - runtimeOptions: Readonly>, + baseRuntimeOptions: Readonly>, private readonly containerScope: FluidObject, // Create a custom ITelemetryBaseLogger to output telemetry events. public readonly baseLogger: ITelemetryBaseLogger, @@ -1577,7 +1571,7 @@ export class ContainerRuntime // the defaults ...DefaultSummaryConfiguration, // the runtime configuration overrides - ...runtimeOptions.summaryOptions?.summaryConfigOverrides, + ...baseRuntimeOptions.summaryOptions?.summaryConfigOverrides, }, recentBatchInfo?: [number, string][], ) { @@ -1610,8 +1604,10 @@ export class ContainerRuntime validateLoaderCompatibility(maybeLoaderCompatDetails.ILayerCompatDetails, this.disposeFn); // Backfill in defaults for the internal runtimeOptions, since they may not be present on the provided runtimeOptions object - this.defaultGlobalIdGenerator = runtimeOptions.defaultGlobalIdGenerator; - + const runtimeOptions = { + flushMode: defaultFlushMode, + ...baseRuntimeOptions, + }; this.mc = createChildMonitoringContext({ logger: this.baseLogger, namespace: "ContainerRuntime", @@ -2148,7 +2144,7 @@ export class ContainerRuntime summaryFormatVersion: metadata?.summaryFormatVersion, disableIsolatedChannels: metadata?.disableIsolatedChannels, gcVersion: metadata?.gcFeature, - options: JSON.stringify(runtimeOptions), + options: JSON.stringify(baseRuntimeOptions), idCompressorModeMetadata: metadata?.documentSchema?.runtime?.idCompressorMode, idCompressorMode: this.sessionSchema.idCompressorMode, sessionRuntimeSchema: JSON.stringify(this.sessionSchema), diff --git a/packages/runtime/datastore/src/dataStoreRuntime.ts b/packages/runtime/datastore/src/dataStoreRuntime.ts index b9a58d21bfd1..8ded05a42811 100644 --- a/packages/runtime/datastore/src/dataStoreRuntime.ts +++ b/packages/runtime/datastore/src/dataStoreRuntime.ts @@ -481,7 +481,7 @@ export class FluidDataStoreRuntime id = typeof res === "number" ? encodeCompactIdToString(2 * res + 1, "_") : res; } } else { - id = `${this.dataStoreContext.containerRuntime.generateDocumentUniqueId()}`; + id = uuid(); } assert(!id.includes("/"), 0x8fc /* slash */); } From 3c77a32aa2cd4c6a7d245d1265a5d467d5090bb5 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Fri, 31 Jan 2025 14:05:15 -0800 Subject: [PATCH 30/54] move to custom id tracking --- .../test/mocha/directoryEquivalenceUtils.ts | 14 +- .../src/ddsModels.ts | 63 +++-- .../src/localServerStressHarness.ts | 22 ++ .../src/stressDataObject.ts | 245 ++++++++++++------ .../src/test/localServerStress.spec.ts | 62 +++-- 5 files changed, 278 insertions(+), 128 deletions(-) diff --git a/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts b/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts index 284776cfbb62..23bea880b1da 100644 --- a/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts +++ b/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts @@ -6,7 +6,7 @@ import { strict as assert } from "node:assert"; import { isObject } from "@fluidframework/core-utils/internal"; -import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; +import { isFluidHandle, toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import type { IDirectory } from "../../interfaces.js"; @@ -47,8 +47,12 @@ async function assertEventualConsistencyCore( isObject(secondVal), `Values differ at key ${key}: first is an object, second is not`, ); - const firstHandle = isFluidHandle(firstVal) ? await firstVal.get() : firstVal; - const secondHandle = isFluidHandle(secondVal) ? await secondVal.get() : secondVal; + const firstHandle = isFluidHandle(firstVal) + ? toFluidHandleInternal(firstVal).absolutePath + : firstVal; + const secondHandle = isFluidHandle(secondVal) + ? toFluidHandleInternal(secondVal).absolutePath + : secondVal; assert.equal( firstHandle, secondHandle, @@ -59,8 +63,8 @@ async function assertEventualConsistencyCore( ); } else { assert.strictEqual( - first.get(key), - second.get(key), + firstVal, + secondVal, `Key not found or value not matching ` + `key: ${key}, value in dir first at path ${first.absolutePath}: ${first.get( key, diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 5da57992ed81..b85f7340387b 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -99,10 +99,23 @@ const createDDSClient = (channel: IChannel): DDSClient => { }; }; -const covertLocalServerStateToDdsState = ( +const covertLocalServerStateToDdsState = async ( state: LocalServerStressState, channel: IChannel, -): DDSFuzzTestState => { +): Promise> => { + const channels = await state.client.entryPoint.channels(); + const allHandles = [ + ...( + await Promise.all( + Object.values(channels) + .flatMap((c) => c) + .map(async (c) => c.channel.handle), + ) + ).filter((v): v is IFluidHandle => v !== undefined), + ...Object.values(state.client.entryPoint.globalObjects) + .map((v) => v.handle) + .filter((v): v is IFluidHandle => v !== undefined), + ]; return { clients: makeUnreachableCodePathProxy("clients"), client: createDDSClient(channel), @@ -112,16 +125,7 @@ const covertLocalServerStateToDdsState = ( random: { ...state.random, handle: () => { - const realHandle = toFluidHandleInternal( - state.random.pick([ - ...Object.values(state.client.entryPoint.channels) - .flatMap((ca) => ca) - .map((c) => c.handle), - ...Object.values(state.client.entryPoint.globalObjects) - .map((v) => v.handle) - .filter((v): v is IFluidHandle => v !== undefined), - ]), - ); + const realHandle = toFluidHandleInternal(state.random.pick(allHandles)); return { absolutePath: realHandle.absolutePath, get [fluidHandleSymbol]() { @@ -142,12 +146,14 @@ const covertLocalServerStateToDdsState = ( export const DDSModelOpGenerator: AsyncGenerator = async ( state, ) => { - const channelType = state.random.pick(Object.keys(state.client.entryPoint.channels)); - const channel = state.random.pick(state.client.entryPoint.channels[channelType]); + const channels = await state.client.entryPoint.channels(); + const channelType = state.random.pick(Object.keys(channels)); + const channel = state.random.pick(channels[channelType]).channel; + assert(channel !== undefined, "channel mist exist"); const model = ddsModelMap.get(channelType); assert(model !== undefined, "must have model"); - const op = await model.generator(covertLocalServerStateToDdsState(state, channel)); + const op = await model.generator(await covertLocalServerStateToDdsState(state, channel)); return { type: "DDSModelOp", @@ -163,33 +169,36 @@ export const DDSModelOpReducer: AsyncReducer ) => { const baseModel = ddsModelMap.get(op.channelType); assert(baseModel !== undefined, "must have model"); - const channel = state.client.entryPoint.channels[op.channelType].find( - (v) => v.id === op.channelId, - ); + const channels = await state.client.entryPoint.channels(); + const channel = channels[op.channelType].find((v) => v.id === op.channelId)?.channel; assert(channel !== undefined, "must have channel"); - await baseModel.reducer(covertLocalServerStateToDdsState(state, channel), op.op as any); + await baseModel.reducer( + await covertLocalServerStateToDdsState(state, channel), + op.op as any, + ); }; export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { - const buildChannelMap = (client: Client) => { + const buildChannelMap = async (client: Client) => { const channelMap = new Map(); for (const value of Object.values(client.entryPoint.globalObjects).map((v) => v.type === "stressDataObject" ? v : undefined, )) { - if (value?.StressDataObject.attached) { - for (const channel of Object.values(value.StressDataObject.channels).flatMap( - (ca) => ca, - )) { + const stressDataObject = await value?.stressDataObject; + if (stressDataObject?.attached) { + const channels = await stressDataObject.channels(); + for (const entry of Object.values(channels).flatMap((ca) => ca)) { + const channel = entry.channel; if (channel.isAttached()) { - channelMap.set(`${value.StressDataObject.id}/${channel.id}`, channel); + channelMap.set(`${stressDataObject.id}/${channel.id}`, channel); } } } } return channelMap; }; - const aMap = buildChannelMap(clientA); - const bMap = buildChannelMap(clientB); + const aMap = await buildChannelMap(clientA); + const bMap = await buildChannelMap(clientB); assert(aMap.size === bMap.size, "channel maps should be the same size"); for (const key of aMap.keys()) { const aChannel = aMap.get(key); diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 291740c8f6a4..475b0bea4262 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -695,6 +695,28 @@ function mixinSynchronization< ), ), ); + const maxSeq = Math.max( + ...connectedClients.map((c) => c.container.deltaManager.lastKnownSeqNumber), + ); + + const makeOpHandler = (container: IContainer, resolve: () => void) => { + if (container.deltaManager.lastKnownSeqNumber < maxSeq) { + const handler = (msg) => { + if (msg.sequenceNumber >= maxSeq) { + container.off("op", handler); + resolve(); + } + }; + container.on("op", handler); + } else { + resolve(); + } + }; + await Promise.all( + connectedClients.map( + async (c) => new Promise((resolve) => makeOpHandler(c.container, resolve)), + ), + ); if (connectedClients.length > 0) { const readonlyChannel = connectedClients[0]; diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index af9a6ac34581..152bcecfd16c 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -11,16 +11,24 @@ import { } from "@fluidframework/container-definitions/internal"; import { loadContainerRuntime, + RuntimeHeaders, type IContainerRuntimeOptionsInternal, } from "@fluidframework/container-runtime/internal"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; -import type { FluidObject } from "@fluidframework/core-interfaces"; +// eslint-disable-next-line import/no-deprecated +import type { IContainerRuntimeWithResolveHandle_Deprecated } from "@fluidframework/container-runtime-definitions/internal"; +import type { + IFluidHandle, + FluidObject, + IFluidLoadable, +} from "@fluidframework/core-interfaces"; import { assert, Lazy, LazyPromise } from "@fluidframework/core-utils/internal"; import type { IChannel } from "@fluidframework/datastore-definitions/internal"; +import { ISharedMap, SharedMap } from "@fluidframework/map/internal"; import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import { ddsModelMap } from "./ddsModels"; +import { makeUnreachableCodePathProxy } from "./localServerStressHarness"; export class StressDataObject extends DataObject { public static readonly factory = new Lazy(() => { @@ -38,82 +46,73 @@ export class StressDataObject extends DataObject { return this; } - protected _globalObjects: Record< - string, - | { type: "newBlob"; handle: IFluidHandle } - | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } - | { - type: "stressDataObject"; - StressDataObject: StressDataObject; - handle: IFluidHandle; - } - | { type: "newAlias"; alias: string } - > = {}; - - public get globalObjects(): Readonly< - Record< - string, - | { type: "newBlob"; handle: IFluidHandle } - | { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle } - | { - type: "stressDataObject"; - StressDataObject: StressDataObject; - handle: IFluidHandle; - } - | { type: "newAlias"; alias: string; handle?: undefined } - > - > { - return this._globalObjects; + private defaultStressObject: DefaultStressDataObject = makeUnreachableCodePathProxy( + "defaultStressDataObject", + ); + public get globalObjects() { + return this.defaultStressObject.globalObjects; } + protected async getDefaultStressDataObject(): Promise { + const defaultDataStore = + await this.context.containerRuntime.getAliasedDataStoreEntryPoint("default"); + assert(defaultDataStore !== undefined, "default must exist"); - protected async getDefaultStressDataObject() { - const root = await this.context.containerRuntime.getAliasedDataStoreEntryPoint("default"); - assert(root !== undefined, "default must exist"); - - const maybe: FluidObject | undefined = await root.get(); - assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); - return maybe.StressDataObject; + const maybe: FluidObject | undefined = + await defaultDataStore.get(); + assert(maybe.DefaultStressDataObject !== undefined, "must be DefaultStressDataObject"); + return maybe.DefaultStressDataObject; } - public get attached() { - return this.runtime.attachState === AttachState.Attached; + private channelNameMap: ISharedMap = makeUnreachableCodePathProxy("channelNameMap"); + protected async initializingFirstTime(props?: any): Promise { + this.channelNameMap = SharedMap.create(this.runtime, "channelNameMap"); + this.channelNameMap.bindToContext(); + this.channelNameMap.set("root", this.root.attributes.type); } - public channels: Record = {}; - - protected async preInitialize(): Promise { - const root = await this.getDefaultStressDataObject(); + public async channels() { + const channels: Record = {}; + for (const [name, type] of this.channelNameMap.entries()) { + const channel = await this.runtime.getChannel(name).catch(() => undefined); + if (channel !== undefined) { + const ofType = (channels[type] ??= []); + ofType.push({ + id: name, + channel, + }); + } + } + return channels; + } - this._globalObjects = root._globalObjects; + protected async hasInitialized(): Promise { + this.defaultStressObject = await this.getDefaultStressDataObject(); - const channels = (this.channels[this.root.attributes.type] ??= []); - channels.push(this.root); + this.channelNameMap = (await this.runtime.getChannel( + "channelNameMap", + )) as any as ISharedMap; + } - setTimeout(() => { - this._globalObjects[this.id] = { - type: "stressDataObject", - StressDataObject: this, - handle: this.handle, - }; - }, 0); + public get attached() { + return this.runtime.attachState === AttachState.Attached; } - public uploadBlob(contents: string) { - void this.runtime.uploadBlob(stringToBuffer(contents, "utf-8")).then( - (handle) => - (this._globalObjects[toFluidHandleInternal(handle).absolutePath] = { - type: "newBlob", - handle, - }), + public uploadBlob(id: `blob-${number}`, contents: string) { + void this.runtime.uploadBlob(stringToBuffer(contents, "utf-8")).then((handle) => + this.defaultStressObject.registerObject({ + type: "newBlob", + handle, + id, + }), ); } - public createChannel(type: string) { - const channels = (this.channels[type] ??= []); - channels.push(this.runtime.createChannel(undefined, type)); + public createChannel(id: `channel-${number}`, type: string) { + this.runtime.createChannel(id, type); + this.channelNameMap.set(id, type); } - public createDataStore(asChild: boolean) { + public createDataStore(id: `datastore-${number}`, asChild: boolean) { void this.context.containerRuntime .createDataStore( asChild @@ -121,31 +120,129 @@ export class StressDataObject extends DataObject { : StressDataObject.factory.value.type, ) .then(async (dataStore) => { - this._globalObjects[dataStore.entryPoint.absolutePath] = { - type: "newDatastore", + this.defaultStressObject.registerObject({ + type: "stressDataObject", dataStore, handle: dataStore.entryPoint, - }; + id, + stressDataObject: new LazyPromise(async () => { + const maybe: FluidObject | undefined = + await dataStore.entryPoint.get(); + assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); + return maybe.StressDataObject; + }), + }); }); } } +export type ContainerObjects = + | { type: "newBlob"; handle: IFluidHandle; id: `blob-${number}` } + | { + type: "stressDataObject"; + id: `datastore-${number}`; + dataStore: IDataStore | undefined; + handle: IFluidHandle; + stressDataObject: LazyPromise; + } + | { type: "newAlias"; id: `alias-${number}`; handle: undefined }; class DefaultStressDataObject extends StressDataObject { public static readonly alias = "default"; - protected override async getDefaultStressDataObject(): Promise { + public get DefaultStressDataObject() { + return this; + } + + private readonly _globalObjects: Record = {}; + public get globalObjects(): Readonly>> { + return this._globalObjects; + } + + protected override async getDefaultStressDataObject(): Promise { return this; } - protected async preInitialize(): Promise { - const channels = (this.channels[this.root.attributes.type] ??= []); - channels.push(this.root); - this._globalObjects[this.id] = { + private map: ISharedMap = makeUnreachableCodePathProxy("map"); + protected async initializingFirstTime(props?: any): Promise { + await super.initializingFirstTime(props); + this.map = SharedMap.create(this.runtime, "privateRoot"); + this.map.bindToContext(); + + this.map.on("valueChanged", (changed) => { + void this.processValue(changed.key); + }); + + this.registerObject({ type: "stressDataObject", - StressDataObject: this, handle: this.handle, - }; - this._globalObjects.default = { type: "newAlias", alias: DefaultStressDataObject.alias }; + id: `datastore-0`, + dataStore: undefined, + stressDataObject: new LazyPromise(async () => this), + }); + } + + protected async initializingFromExisting(): Promise { + this.map = (await this.runtime.getChannel("privateRoot")) as any as ISharedMap; + void Promise.resolve().then(async () => { + for (const url of this.map.keys()) { + await this.processValue(url); + } + }); + } + + private async processValue(url: string) { + const containerRuntime = // eslint-disable-next-line import/no-deprecated + this.context.containerRuntime as IContainerRuntimeWithResolveHandle_Deprecated; + const resp = await containerRuntime.resolveHandle({ + url, + headers: { [RuntimeHeaders.wait]: false }, + }); + if (resp.status === 200) { + const maybeHandle: FluidObject | undefined = resp.value; + const handle = maybeHandle?.IFluidLoadable?.handle; + if (handle !== undefined) { + const entry = this.map.get(url); + switch (entry?.type) { + case "newAlias": + this.registerObject({ + ...entry, + handle: undefined, + }); + break; + case "newBlob": + this.registerObject({ + ...entry, + handle, + }); + break; + case "stressDataObject": + this.registerObject({ + type: "stressDataObject", + id: entry.id, + dataStore: undefined, + handle, + stressDataObject: new LazyPromise(async () => { + const maybe = (await handle.get()) as + | FluidObject + | undefined; + assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); + return maybe.StressDataObject; + }), + }); + break; + default: + } + } + } + } + + public registerObject(obj: ContainerObjects) { + if (obj.handle) { + const handle = toFluidHandleInternal(obj.handle); + if (this.map.get(handle.absolutePath) === undefined) { + this.map.set(handle.absolutePath, { id: obj.id, type: obj.type }); + } + } } } @@ -158,7 +255,6 @@ export const createRuntimeFactory = (): IRuntimeFactory => { [[StressDataObject.factory.value.type, StressDataObject.factory.value]], ); - let id = 0; const runtimeOptions: IContainerRuntimeOptionsInternal = { summaryOptions: { summaryConfigOverrides: { @@ -166,7 +262,6 @@ export const createRuntimeFactory = (): IRuntimeFactory => { initialSummarizerDelayMs: 0, } as any, }, - defaultGlobalIdGenerator: () => `${id++}`, }; return { diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 1d344c1c5126..1009d50a2898 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -11,9 +11,7 @@ import { createWeightedAsyncGenerator, takeAsync, } from "@fluid-private/stochastic-test-utils"; -import { type IFluidHandle } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; -import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { ddsModelMap, @@ -27,24 +25,29 @@ import { LocalServerStressModel, type LocalServerStressState, } from "../localServerStressHarness"; +import type { ContainerObjects } from "../stressDataObject.js"; import { _dirname } from "./dirname.cjs"; interface UploadBlob { type: "uploadBlob"; + id: `blob-${number}`; } interface AliasDataStore { type: "aliasDataStore"; - id: string; + datastoreId: `datastore-${number}`; + alias: string; } interface CreateDataStore { type: "createDataStore"; asChild: boolean; + id: `datastore-${number}`; } interface CreateChannel { type: "createChannel"; channelType: string; + id: `channel-${number}`; } type StressOperations = @@ -56,39 +59,42 @@ type StressOperations = const reducer = combineReducersAsync({ aliasDataStore: async (state, op) => { - const entry = state.client.entryPoint.globalObjects[op.id]; - assert(entry.type === "newDatastore", "must be a new datastore"); + const entry = state.client.entryPoint.globalObjects[op.datastoreId]; + assert( + entry.type === "stressDataObject" && entry.dataStore !== undefined, + "must be a new datastore", + ); - void entry.dataStore.trySetAlias(String.fromCodePoint(state.random.integer(0, 26) + 65)); + void entry.dataStore.trySetAlias(op.alias); }, createDataStore: async (state, op) => { - state.client.entryPoint.createDataStore(op.asChild); + state.client.entryPoint.createDataStore(op.id, op.asChild); }, createChannel: async (state, op) => { - state.client.entryPoint.createChannel(op.channelType); + state.client.entryPoint.createChannel(op.id, op.channelType); }, - uploadBlob: async (state) => { - state.client.entryPoint.uploadBlob(state.random.string(state.random.integer(1, 16))); + uploadBlob: async (state, op) => { + state.client.entryPoint.uploadBlob( + op.id, + state.random.string(state.random.integer(1, 16)), + ); }, DDSModelOp: DDSModelOpReducer, }); +let id = 0; function makeGenerator(): AsyncGenerator { const aliasDataStore: AsyncGenerator = async ( state, ) => { const newDataStores = Object.entries(state.client.entryPoint.globalObjects).filter( - ( - e, - ): e is [ - string, - { type: "newDatastore"; dataStore: IDataStore; handle: IFluidHandle }, - ] => e[1].type === "newDatastore", + (e): e is [string, Extract] => + e[1].type === "stressDataObject" && e[1].dataStore !== undefined, ); - const [id] = state.random.pick(newDataStores); return { type: "aliasDataStore", - id, + datastoreId: state.random.pick(newDataStores)[1].id, + alias: `alias-${state.random.integer(0, 10)}`, } satisfies AliasDataStore; }; @@ -99,15 +105,29 @@ function makeGenerator(): AsyncGenerator Object.values(state.client.entryPoint.globalObjects).some( - (v) => v.type === "newDatastore", + (v) => v.type === "stressDataObject" && v.dataStore !== undefined, ), ], - [async (state) => ({ type: "createDataStore", asChild: state.random.bool() }), 2], - [{ type: "uploadBlob" }, 2], + [ + async (state) => ({ + type: "createDataStore", + asChild: state.random.bool(), + id: `datastore-${++id}`, + }), + 2, + ], + [ + async (state) => ({ + type: "uploadBlob", + id: `blob-${++id}`, + }), + 2, + ], [ async (state) => ({ type: "createChannel", channelType: state.random.pick([...ddsModelMap.keys()]), + id: `channel-${++id}`, }), 3, ], From 442b23651c798927b6975703bbf793b3eb2e1cbd Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Fri, 31 Jan 2025 15:35:38 -0800 Subject: [PATCH 31/54] channel handles --- .../test/local-server-stress-tests/src/ddsModels.ts | 11 ++++------- .../src/localServerStressHarness.ts | 6 +++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index b85f7340387b..9f901ff426bb 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -105,13 +105,10 @@ const covertLocalServerStateToDdsState = async ( ): Promise> => { const channels = await state.client.entryPoint.channels(); const allHandles = [ - ...( - await Promise.all( - Object.values(channels) - .flatMap((c) => c) - .map(async (c) => c.channel.handle), - ) - ).filter((v): v is IFluidHandle => v !== undefined), + ...Object.values(channels) + .flatMap((c) => c) + .map((c) => c.channel.handle) + .filter((v): v is IFluidHandle => v !== undefined), ...Object.values(state.client.entryPoint.globalObjects) .map((v) => v.handle) .filter((v): v is IFluidHandle => v !== undefined), diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 475b0bea4262..533d6043025c 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -788,9 +788,9 @@ function mixinClientSelection< assert(hasSelectedClientSpec(operation), "operation should have been given a client"); const client = state.clients.find((c) => c.id === operation.clientId); assert(client !== undefined); - await runInStateWithClient(state, client, async () => - model.reducer(state, operation as TOperation), - ); + await runInStateWithClient(state, client, async () => { + await model.reducer(state, operation as TOperation); + }); }; return { ...model, From 8579ed029e626b1363649ebd15abe59448d6bd17 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Fri, 31 Jan 2025 17:17:10 -0800 Subject: [PATCH 32/54] round trip handles --- .../src/ddsModels.ts | 45 +++++++++++++------ .../src/test/localServerStress.spec.ts | 5 +-- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 9f901ff426bb..65f6108126e4 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -13,8 +13,8 @@ import { DDSFuzzTestState, Client as DDSClient, } from "@fluid-private/test-dds-utils"; -import { IFluidHandle, fluidHandleSymbol } from "@fluidframework/core-interfaces"; -import { assert } from "@fluidframework/core-utils/internal"; +import { fluidHandleSymbol } from "@fluidframework/core-interfaces"; +import { assert, isObject } from "@fluidframework/core-utils/internal"; import type { IChannel, IChannelFactory, @@ -107,11 +107,10 @@ const covertLocalServerStateToDdsState = async ( const allHandles = [ ...Object.values(channels) .flatMap((c) => c) - .map((c) => c.channel.handle) - .filter((v): v is IFluidHandle => v !== undefined), - ...Object.values(state.client.entryPoint.globalObjects) - .map((v) => v.handle) - .filter((v): v is IFluidHandle => v !== undefined), + .map((c) => ({ id: c.id, handle: c.channel.handle })), + ...Object.values(state.client.entryPoint.globalObjects).filter( + (v) => v.handle !== undefined, + ), ]; return { clients: makeUnreachableCodePathProxy("clients"), @@ -122,8 +121,11 @@ const covertLocalServerStateToDdsState = async ( random: { ...state.random, handle: () => { - const realHandle = toFluidHandleInternal(state.random.pick(allHandles)); + const { id, handle } = state.random.pick(allHandles); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const realHandle = toFluidHandleInternal(handle!); return { + id, absolutePath: realHandle.absolutePath, get [fluidHandleSymbol]() { return realHandle[fluidHandleSymbol]; @@ -143,10 +145,11 @@ const covertLocalServerStateToDdsState = async ( export const DDSModelOpGenerator: AsyncGenerator = async ( state, ) => { + // we need to look at other objects, not just the entrypoint const channels = await state.client.entryPoint.channels(); const channelType = state.random.pick(Object.keys(channels)); const channel = state.random.pick(channels[channelType]).channel; - assert(channel !== undefined, "channel mist exist"); + assert(channel !== undefined, "channel must exist"); const model = ddsModelMap.get(channelType); assert(model !== undefined, "must have model"); @@ -169,10 +172,26 @@ export const DDSModelOpReducer: AsyncReducer const channels = await state.client.entryPoint.channels(); const channel = channels[op.channelType].find((v) => v.id === op.channelId)?.channel; assert(channel !== undefined, "must have channel"); - await baseModel.reducer( - await covertLocalServerStateToDdsState(state, channel), - op.op as any, - ); + + const allHandles = [ + ...Object.values(channels) + .flatMap((c) => c) + .map((c) => ({ id: c.id, handle: c.channel.handle })), + ...Object.values(state.client.entryPoint.globalObjects).filter( + (v) => v.handle !== undefined, + ), + ]; + + const subOp = JSON.parse(JSON.stringify(op.op), (key, value) => { + if (isObject(value) && "absolutePath" in value && "id" in value) { + const handle = allHandles.find((h) => h.id === value.id); + assert(handle !== undefined, "handle must exist"); + return handle; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value; + }); + await baseModel.reducer(await covertLocalServerStateToDdsState(state, channel), subOp); }; export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 1009d50a2898..91b3b5372d1b 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -156,10 +156,9 @@ describe("Local Server Stress", () => { clientAddProbability: 0.1, }, reconnectProbability: 0.1, - skipMinimization: true, + // skipMinimization: true, // Uncomment to replay a particular seed. - // replay: 24, + // replay: 5, saveFailures, - saveSuccesses, }); }); From a909d69c56d4dfef8922a8cf6afa22493ea30ed4 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 13:24:27 -0800 Subject: [PATCH 33/54] fix generation issues --- packages/dds/map/src/test/mocha/fuzzUtils.ts | 24 +++--- .../src/localServerStressHarness.ts | 19 ++--- .../src/test/localServerStress.spec.ts | 74 ++++++++++--------- 3 files changed, 61 insertions(+), 56 deletions(-) diff --git a/packages/dds/map/src/test/mocha/fuzzUtils.ts b/packages/dds/map/src/test/mocha/fuzzUtils.ts index 883cf5d68e16..0d0234a46e9e 100644 --- a/packages/dds/map/src/test/mocha/fuzzUtils.ts +++ b/packages/dds/map/src/test/mocha/fuzzUtils.ts @@ -428,15 +428,17 @@ interface LoggingInfo { } function logCurrentState(clients: Client[], loggingInfo: LoggingInfo): void { - for (const id of loggingInfo.clientIds) { - const { channel: sharedDirectory } = - clients.find((s) => s.containerRuntime.clientId === id) ?? {}; - if (sharedDirectory !== undefined) { - console.log(`Client ${id}:`); - console.log( - JSON.stringify(sharedDirectory.getAttachSummary(true).summary, undefined, 4), - ); - console.log("\n"); + if (loggingInfo.printConsoleLogs === true) { + for (const id of loggingInfo.clientIds) { + const { channel: sharedDirectory } = + clients.find((s) => s.containerRuntime.clientId === id) ?? {}; + if (sharedDirectory !== undefined) { + console.log(`Client ${id}:`); + console.log( + JSON.stringify(sharedDirectory.getAttachSummary(true).summary, undefined, 4), + ); + console.log("\n"); + } } } } @@ -452,7 +454,7 @@ export function makeDirReducer( const withLogging = (baseReducer: AsyncReducer): AsyncReducer => async (state, operation) => { - if (loggingInfo !== undefined && loggingInfo.printConsoleLogs) { + if (loggingInfo?.printConsoleLogs === true) { logCurrentState(state.clients, loggingInfo); console.log("-".repeat(20)); console.log("Next operation:", JSON.stringify(operation, undefined, 4)); @@ -460,7 +462,7 @@ export function makeDirReducer( try { await baseReducer(state, operation); } catch (error) { - if (loggingInfo !== undefined) { + if (loggingInfo?.printConsoleLogs === true) { logCurrentState(state.clients, loggingInfo); } throw error; diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 533d6043025c..bbcd6c1e93f1 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -456,7 +456,6 @@ function mixinAddRemoveClient< return async ( state: TState, ): Promise => { - const baseOp = baseGenerator(state); const { clients, random, isDetached, containerUrl } = state; if ( containerUrl !== undefined && @@ -475,11 +474,11 @@ function mixinAddRemoveClient< return { type: "addClient", url: containerUrl, - id: makeFriendlyClientId(random, clients.length), + id: makeFriendlyClientId(random), } satisfies AddClient; } } - return baseOp; + return baseGenerator(state); }; }; @@ -568,7 +567,7 @@ function mixinAttach( package: "local-server-stress-tests", }; const codeLoader = new LocalCodeLoader([[codeDetails, createRuntimeFactory()]]); + clientCount = 0; const initialClient = await createDetachedClient( localDeltaConnectionServer, codeLoader, codeDetails, - startDetached ? makeFriendlyClientId(random, 0) : "original", + startDetached ? makeFriendlyClientId(random) : "original", ); const clients: Client[] = [initialClient]; let containerUrl: string | undefined; @@ -925,7 +927,7 @@ async function runTestForSeed( loadClient( localDeltaConnectionServer, codeLoader, - makeFriendlyClientId(random, i), + makeFriendlyClientId(random), url, ), ), @@ -1179,7 +1181,6 @@ export function mixinReconnect< () => { const baseGenerator = model.generatorFactory(); return async (state): Promise => { - const baseOp = baseGenerator(state); if (!state.isDetached && state.random.bool(options.reconnectProbability)) { const client = state.clients.find((c) => c.id === state.client.id); assert(client !== undefined); @@ -1189,7 +1190,7 @@ export function mixinReconnect< }; } - return baseOp; + return baseGenerator(state); }; }; diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 91b3b5372d1b..967f9d541f7f 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -98,44 +98,45 @@ function makeGenerator(): AsyncGenerator( + const asyncGenerator = createWeightedAsyncGenerator< + StressOperations, + LocalServerStressState + >([ [ - [ - aliasDataStore, - 1, - (state) => - Object.values(state.client.entryPoint.globalObjects).some( - (v) => v.type === "stressDataObject" && v.dataStore !== undefined, - ), - ], - [ - async (state) => ({ - type: "createDataStore", - asChild: state.random.bool(), - id: `datastore-${++id}`, - }), - 2, - ], - [ - async (state) => ({ - type: "uploadBlob", - id: `blob-${++id}`, - }), - 2, - ], - [ - async (state) => ({ - type: "createChannel", - channelType: state.random.pick([...ddsModelMap.keys()]), - id: `channel-${++id}`, - }), - 3, - ], - [DDSModelOpGenerator, 4], + aliasDataStore, + 1, + (state) => + Object.values(state.client.entryPoint.globalObjects).some( + (v) => v.type === "stressDataObject" && v.dataStore !== undefined, + ), ], - ); + [ + async (state) => ({ + type: "createDataStore", + asChild: state.random.bool(), + id: `datastore-${++id}`, + }), + 2, + ], + [ + async (state) => ({ + type: "uploadBlob", + id: `blob-${++id}`, + }), + 2, + ], + [ + async (state) => ({ + type: "createChannel", + channelType: state.random.pick([...ddsModelMap.keys()]), + id: `channel-${++id}`, + }), + 3, + ], + [DDSModelOpGenerator, 4], + ]); - return async (state) => syncGenerator(state); + return async (state) => asyncGenerator(state); } export const saveFailures = { directory: path.join(_dirname, "../../src/test/results") }; export const saveSuccesses = { directory: path.join(_dirname, "../../src/test/results") }; @@ -156,9 +157,10 @@ describe("Local Server Stress", () => { clientAddProbability: 0.1, }, reconnectProbability: 0.1, - // skipMinimization: true, + skipMinimization: true, // Uncomment to replay a particular seed. // replay: 5, saveFailures, + saveSuccesses, }); }); From b75f336bc27495fb210ffb3923e4add9560a8bd6 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 14:20:26 -0800 Subject: [PATCH 34/54] register objects --- packages/test/local-server-stress-tests/src/stressDataObject.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index 152bcecfd16c..afe766dc9584 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -243,6 +243,7 @@ class DefaultStressDataObject extends StressDataObject { this.map.set(handle.absolutePath, { id: obj.id, type: obj.type }); } } + this._globalObjects[obj.id] = obj; } } From f2bbc56b316e854e753da70ff4e53d22aa282ddc Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 14:54:20 -0800 Subject: [PATCH 35/54] move channel and datastore selection to harness --- .../src/ddsModels.ts | 37 ++++++---------- .../src/localServerStressHarness.ts | 43 ++++++++++++++++--- .../src/stressDataObject.ts | 10 ++--- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 65f6108126e4..aff962c6456f 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -101,20 +101,17 @@ const createDDSClient = (channel: IChannel): DDSClient => { const covertLocalServerStateToDdsState = async ( state: LocalServerStressState, - channel: IChannel, ): Promise> => { - const channels = await state.client.entryPoint.channels(); + const channels = await state.datastore.channels(); const allHandles = [ - ...Object.values(channels) - .flatMap((c) => c) - .map((c) => ({ id: c.id, handle: c.channel.handle })), + ...channels.map((c) => ({ id: c.id, handle: c.handle })), ...Object.values(state.client.entryPoint.globalObjects).filter( (v) => v.handle !== undefined, ), ]; return { clients: makeUnreachableCodePathProxy("clients"), - client: createDDSClient(channel), + client: createDDSClient(state.channel), containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), isDetached: state.isDetached, summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), @@ -145,19 +142,15 @@ const covertLocalServerStateToDdsState = async ( export const DDSModelOpGenerator: AsyncGenerator = async ( state, ) => { - // we need to look at other objects, not just the entrypoint - const channels = await state.client.entryPoint.channels(); - const channelType = state.random.pick(Object.keys(channels)); - const channel = state.random.pick(channels[channelType]).channel; - assert(channel !== undefined, "channel must exist"); - const model = ddsModelMap.get(channelType); + const channel = state.channel; + const model = ddsModelMap.get(channel.attributes.type); assert(model !== undefined, "must have model"); - const op = await model.generator(await covertLocalServerStateToDdsState(state, channel)); + const op = await model.generator(await covertLocalServerStateToDdsState(state)); return { type: "DDSModelOp", - channelType, + channelType: channel.attributes.type, channelId: channel.id, op, } satisfies DDSModelOp; @@ -168,15 +161,10 @@ export const DDSModelOpReducer: AsyncReducer op, ) => { const baseModel = ddsModelMap.get(op.channelType); - assert(baseModel !== undefined, "must have model"); - const channels = await state.client.entryPoint.channels(); - const channel = channels[op.channelType].find((v) => v.id === op.channelId)?.channel; - assert(channel !== undefined, "must have channel"); - + assert(baseModel !== undefined, "must have base model"); + const channels = await state.datastore.channels(); const allHandles = [ - ...Object.values(channels) - .flatMap((c) => c) - .map((c) => ({ id: c.id, handle: c.channel.handle })), + ...channels.map((c) => ({ id: c.id, handle: c.handle })), ...Object.values(state.client.entryPoint.globalObjects).filter( (v) => v.handle !== undefined, ), @@ -191,7 +179,7 @@ export const DDSModelOpReducer: AsyncReducer // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }); - await baseModel.reducer(await covertLocalServerStateToDdsState(state, channel), subOp); + await baseModel.reducer(await covertLocalServerStateToDdsState(state), subOp); }; export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { @@ -203,8 +191,7 @@ export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Clie const stressDataObject = await value?.stressDataObject; if (stressDataObject?.attached) { const channels = await stressDataObject.channels(); - for (const entry of Object.values(channels).flatMap((ca) => ca)) { - const channel = entry.channel; + for (const channel of channels) { if (channel.isAttached()) { channelMap.set(`${stressDataObject.id}/${channel.id}`, channel); } diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index bbcd6c1e93f1..253d7609fcda 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -42,6 +42,7 @@ import { } from "@fluidframework/container-loader/internal"; import type { FluidObject } from "@fluidframework/core-interfaces"; import { unreachableCase } from "@fluidframework/core-utils/internal"; +import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import { createLocalResolverCreateNewRequest, LocalDocumentServiceFactory, @@ -78,6 +79,8 @@ export interface LocalServerStressState extends BaseFuzzTestState { random: IRandom; clients: Client[]; client: Client; + datastore: StressDataObject; + channel: IChannel; isDetached: boolean; } @@ -86,6 +89,8 @@ export interface LocalServerStressState extends BaseFuzzTestState { */ interface SelectedClientSpec { clientId: string; + datastoreId: string; + channelId: string; } /** @@ -771,23 +776,41 @@ function mixinClientSelection< // 2. Make it available to the subsequent reducer logic we're going to inject // (so that we can recover the channel from serialized data) const client = state.random.pick(state.clients); - const baseOp = await runInStateWithClient(state, client, async () => + const entry = state.random.pick( + Object.values(client.entryPoint.globalObjects).filter( + (v) => v.type === "stressDataObject", + ), + ); + assert(entry?.type === "stressDataObject"); + const datastore = await entry.stressDataObject; + const channels = await datastore.StressDataObject.channels(); + const channel = state.random.pick(channels); + assert(channel !== undefined, "channel must exist"); + const baseOp = await runInStateWithClient(state, client, datastore, channel, async () => baseGenerator(state), ); return baseOp === done ? done - : { + : ({ ...baseOp, clientId: client.id, - }; + datastoreId: entry.id, + channelId: channel.id, + } satisfies SelectedClientSpec); }; }; const reducer: AsyncReducer = async (state, operation) => { assert(hasSelectedClientSpec(operation), "operation should have been given a client"); const client = state.clients.find((c) => c.id === operation.clientId); + const entry = client?.entryPoint.globalObjects[operation.datastoreId]; + assert(entry?.type === "stressDataObject"); + const datastore = await entry.stressDataObject; + const channels = await datastore.StressDataObject.channels(); + const channel = channels.find((c) => c.id === operation.channelId); + assert(channel !== undefined, "channel must exist"); assert(client !== undefined); - await runInStateWithClient(state, client, async () => { + await runInStateWithClient(state, client, datastore, channel, async () => { await model.reducer(state, operation as TOperation); }); }; @@ -807,16 +830,22 @@ function mixinClientSelection< async function runInStateWithClient( state: TState, client: TState["client"], + datastore: TState["datastore"], + channel: TState["channel"], callback: (state: TState) => Promise, ): Promise { - const oldClient = state.client; + const old = { ...state }; state.client = client; + state.datastore = datastore; + state.channel = channel; try { return await callback(state); } finally { // This code is explicitly trying to "update" to the old value. - state.client = oldClient; + state.client = old.client; + state.datastore = old.datastore; + state.channel = old.channel; } } @@ -941,6 +970,8 @@ async function runTestForSeed( codeLoader, random, client: makeUnreachableCodePathProxy("client"), + datastore: makeUnreachableCodePathProxy("datastore"), + channel: makeUnreachableCodePathProxy("channel"), isDetached: startDetached, containerUrl, }; diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index afe766dc9584..94eec18ac00d 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -71,15 +71,11 @@ export class StressDataObject extends DataObject { } public async channels() { - const channels: Record = {}; - for (const [name, type] of this.channelNameMap.entries()) { + const channels: IChannel[] = []; + for (const [name] of this.channelNameMap.entries()) { const channel = await this.runtime.getChannel(name).catch(() => undefined); if (channel !== undefined) { - const ofType = (channels[type] ??= []); - ofType.push({ - id: name, - channel, - }); + channels.push(channel); } } return channels; From b74b48a30ad54a94668ee85032d7b4e43fa5e6bc Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 15:30:09 -0800 Subject: [PATCH 36/54] small fixes --- .../test/mocha/directoryEquivalenceUtils.ts | 2 +- .../src/ddsModels.ts | 8 +-- .../src/localServerStressHarness.ts | 15 ++++- .../src/test/localServerStress.spec.ts | 65 +++---------------- 4 files changed, 28 insertions(+), 62 deletions(-) diff --git a/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts b/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts index 23bea880b1da..101611b92949 100644 --- a/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts +++ b/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts @@ -42,7 +42,7 @@ async function assertEventualConsistencyCore( for (const key of first.keys()) { const firstVal: unknown = first.get(key); const secondVal: unknown = second.get(key); - if (isObject(firstVal) === true) { + if (isObject(firstVal) === true || isObject(secondVal)) { assert( isObject(secondVal), `Values differ at key ${key}: first is an object, second is not`, diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index aff962c6456f..3708d91f0de3 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -172,9 +172,9 @@ export const DDSModelOpReducer: AsyncReducer const subOp = JSON.parse(JSON.stringify(op.op), (key, value) => { if (isObject(value) && "absolutePath" in value && "id" in value) { - const handle = allHandles.find((h) => h.id === value.id); - assert(handle !== undefined, "handle must exist"); - return handle; + const entry = allHandles.find((h) => h.id === value.id); + assert(entry !== undefined, "entry must exist"); + return entry.handle; } // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; @@ -189,7 +189,7 @@ export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Clie v.type === "stressDataObject" ? v : undefined, )) { const stressDataObject = await value?.stressDataObject; - if (stressDataObject?.attached) { + if (stressDataObject?.attached === true) { const channels = await stressDataObject.channels(); for (const channel of channels) { if (channel.isAttached()) { diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 253d7609fcda..09e12a5ed569 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -688,7 +688,18 @@ function mixinSynchronization< // TODO: Only synchronize listed clients if specified if (isSynchronizeOp(operation)) { const connectedClients = state.clients.filter( - (client) => client.container.connectionState === ConnectionState.Connected, + (client) => client.container.connectionState !== ConnectionState.Disconnected, + ); + + await Promise.all( + connectedClients.map( + async (c) => + new Promise((resolve) => + c.container.connectionState !== ConnectionState.Connected + ? c.container.once("connected", () => resolve()) + : resolve(), + ), + ), ); await Promise.all( @@ -729,7 +740,7 @@ function mixinSynchronization< await model.validateConsistency(readonlyChannel, client); } catch (error: unknown) { if (error instanceof Error) { - error.message = `Comparing client ${readonlyChannel.container.clientId} vs client ${client.container.clientId}\n${error.message}`; + error.message = `Comparing client ${readonlyChannel.id} vs client ${client.id}\n${error.message}`; } throw error; } diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 967f9d541f7f..f9ec7e8f0fe1 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -11,7 +11,6 @@ import { createWeightedAsyncGenerator, takeAsync, } from "@fluid-private/stochastic-test-utils"; -import { assert } from "@fluidframework/core-utils/internal"; import { ddsModelMap, @@ -25,7 +24,6 @@ import { LocalServerStressModel, type LocalServerStressState, } from "../localServerStressHarness"; -import type { ContainerObjects } from "../stressDataObject.js"; import { _dirname } from "./dirname.cjs"; @@ -33,11 +31,6 @@ interface UploadBlob { type: "uploadBlob"; id: `blob-${number}`; } -interface AliasDataStore { - type: "aliasDataStore"; - datastoreId: `datastore-${number}`; - alias: string; -} interface CreateDataStore { type: "createDataStore"; asChild: boolean; @@ -50,80 +43,41 @@ interface CreateChannel { id: `channel-${number}`; } -type StressOperations = - | UploadBlob - | AliasDataStore - | CreateDataStore - | CreateChannel - | DDSModelOp; +type StressOperations = UploadBlob | CreateDataStore | CreateChannel | DDSModelOp; const reducer = combineReducersAsync({ - aliasDataStore: async (state, op) => { - const entry = state.client.entryPoint.globalObjects[op.datastoreId]; - assert( - entry.type === "stressDataObject" && entry.dataStore !== undefined, - "must be a new datastore", - ); - - void entry.dataStore.trySetAlias(op.alias); - }, createDataStore: async (state, op) => { - state.client.entryPoint.createDataStore(op.id, op.asChild); + state.datastore.createDataStore(op.id, op.asChild); }, createChannel: async (state, op) => { - state.client.entryPoint.createChannel(op.id, op.channelType); + state.datastore.createChannel(op.id, op.channelType); }, uploadBlob: async (state, op) => { - state.client.entryPoint.uploadBlob( - op.id, - state.random.string(state.random.integer(1, 16)), - ); + state.datastore.uploadBlob(op.id, state.random.string(state.random.integer(1, 16))); }, DDSModelOp: DDSModelOpReducer, }); let id = 0; function makeGenerator(): AsyncGenerator { - const aliasDataStore: AsyncGenerator = async ( - state, - ) => { - const newDataStores = Object.entries(state.client.entryPoint.globalObjects).filter( - (e): e is [string, Extract] => - e[1].type === "stressDataObject" && e[1].dataStore !== undefined, - ); - return { - type: "aliasDataStore", - datastoreId: state.random.pick(newDataStores)[1].id, - alias: `alias-${state.random.integer(0, 10)}`, - } satisfies AliasDataStore; - }; - const asyncGenerator = createWeightedAsyncGenerator< StressOperations, LocalServerStressState >([ - [ - aliasDataStore, - 1, - (state) => - Object.values(state.client.entryPoint.globalObjects).some( - (v) => v.type === "stressDataObject" && v.dataStore !== undefined, - ), - ], [ async (state) => ({ type: "createDataStore", asChild: state.random.bool(), id: `datastore-${++id}`, }), - 2, + 1, ], [ async (state) => ({ type: "uploadBlob", id: `blob-${++id}`, }), - 2, + 10, ], [ async (state) => ({ @@ -131,9 +85,9 @@ function makeGenerator(): AsyncGenerator asyncGenerator(state); @@ -159,7 +113,8 @@ describe("Local Server Stress", () => { reconnectProbability: 0.1, skipMinimization: true, // Uncomment to replay a particular seed. - // replay: 5, + // replay: 98, + // only: [98], saveFailures, saveSuccesses, }); From cfc8f93e730f4ccb1e5d398d202b279c68fd49f7 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 15:33:30 -0800 Subject: [PATCH 37/54] fix sequence validation --- packages/dds/sequence/src/test/intervalTestUtils.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/dds/sequence/src/test/intervalTestUtils.ts b/packages/dds/sequence/src/test/intervalTestUtils.ts index baa4a331a6a3..5a306b7cc33e 100644 --- a/packages/dds/sequence/src/test/intervalTestUtils.ts +++ b/packages/dds/sequence/src/test/intervalTestUtils.ts @@ -6,7 +6,7 @@ import { strict as assert } from "assert"; import { isObject } from "@fluidframework/core-utils/internal"; -import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; +import { isFluidHandle, toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import { MockContainerRuntimeForReconnection } from "@fluidframework/test-runtime-utils/internal"; import { IIntervalCollection } from "../intervalCollection.js"; @@ -129,8 +129,14 @@ async function assertPropertiesEqual(a: SharedString, b: SharedString): Promise< for (const key of aKeys.concat(bKeys)) { const aVal: unknown = aProps[key]; const bVal: unknown = bProps[key]; - const aHandle = isObject(aVal) && isFluidHandle(aVal) ? await aVal.get() : aVal; - const bHandle = isObject(bVal) && isFluidHandle(bVal) ? await bVal.get() : bVal; + const aHandle = + isObject(aVal) && isFluidHandle(aVal) + ? toFluidHandleInternal(aVal).absolutePath + : aVal; + const bHandle = + isObject(bVal) && isFluidHandle(bVal) + ? toFluidHandleInternal(bVal).absolutePath + : bVal; assert.deepEqual( aHandle, bHandle, From 2efdae9b14fa3ac7316037e95a8e21b6c885a9dc Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 16:39:03 -0800 Subject: [PATCH 38/54] fix global object registration --- .../src/ddsModels.ts | 23 ++-- .../src/localServerStressHarness.ts | 10 +- .../src/stressDataObject.ts | 111 ++++++++---------- 3 files changed, 69 insertions(+), 75 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 3708d91f0de3..c448fb336831 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -105,7 +105,7 @@ const covertLocalServerStateToDdsState = async ( const channels = await state.datastore.channels(); const allHandles = [ ...channels.map((c) => ({ id: c.id, handle: c.handle })), - ...Object.values(state.client.entryPoint.globalObjects).filter( + ...Object.values(await state.client.entryPoint.globalObjects()).filter( (v) => v.handle !== undefined, ), ]; @@ -163,11 +163,10 @@ export const DDSModelOpReducer: AsyncReducer const baseModel = ddsModelMap.get(op.channelType); assert(baseModel !== undefined, "must have base model"); const channels = await state.datastore.channels(); + const globalObjects = await state.client.entryPoint.globalObjects(); const allHandles = [ ...channels.map((c) => ({ id: c.id, handle: c.handle })), - ...Object.values(state.client.entryPoint.globalObjects).filter( - (v) => v.handle !== undefined, - ), + ...Object.values(globalObjects).filter((v) => v.handle !== undefined), ]; const subOp = JSON.parse(JSON.stringify(op.op), (key, value) => { @@ -185,15 +184,17 @@ export const DDSModelOpReducer: AsyncReducer export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { const buildChannelMap = async (client: Client) => { const channelMap = new Map(); - for (const value of Object.values(client.entryPoint.globalObjects).map((v) => + for (const entry of Object.values(await client.entryPoint.globalObjects()).map((v) => v.type === "stressDataObject" ? v : undefined, )) { - const stressDataObject = await value?.stressDataObject; - if (stressDataObject?.attached === true) { - const channels = await stressDataObject.channels(); - for (const channel of channels) { - if (channel.isAttached()) { - channelMap.set(`${stressDataObject.id}/${channel.id}`, channel); + if (entry !== undefined) { + const stressDataObject = await entry?.stressDataObject; + if (stressDataObject?.attached === true) { + const channels = await stressDataObject.channels(); + for (const channel of channels) { + if (channel.isAttached()) { + channelMap.set(`${entry.id}/${channel.id}`, channel); + } } } } diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 09e12a5ed569..87c9a178c59d 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -787,10 +787,9 @@ function mixinClientSelection< // 2. Make it available to the subsequent reducer logic we're going to inject // (so that we can recover the channel from serialized data) const client = state.random.pick(state.clients); + const globalObjects = await client.entryPoint.globalObjects(); const entry = state.random.pick( - Object.values(client.entryPoint.globalObjects).filter( - (v) => v.type === "stressDataObject", - ), + Object.values(globalObjects).filter((v) => v.type === "stressDataObject"), ); assert(entry?.type === "stressDataObject"); const datastore = await entry.stressDataObject; @@ -814,13 +813,14 @@ function mixinClientSelection< const reducer: AsyncReducer = async (state, operation) => { assert(hasSelectedClientSpec(operation), "operation should have been given a client"); const client = state.clients.find((c) => c.id === operation.clientId); - const entry = client?.entryPoint.globalObjects[operation.datastoreId]; + assert(client !== undefined); + const globalObjects = await client.entryPoint.globalObjects(); + const entry = globalObjects[operation.datastoreId]; assert(entry?.type === "stressDataObject"); const datastore = await entry.stressDataObject; const channels = await datastore.StressDataObject.channels(); const channel = channels.find((c) => c.id === operation.channelId); assert(channel !== undefined, "channel must exist"); - assert(client !== undefined); await runInStateWithClient(state, client, datastore, channel, async () => { await model.reducer(state, operation as TOperation); }); diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index 94eec18ac00d..eaa16d94600d 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -49,8 +49,8 @@ export class StressDataObject extends DataObject { private defaultStressObject: DefaultStressDataObject = makeUnreachableCodePathProxy( "defaultStressDataObject", ); - public get globalObjects() { - return this.defaultStressObject.globalObjects; + public async globalObjects() { + return this.defaultStressObject.globalObjects(); } protected async getDefaultStressDataObject(): Promise { const defaultDataStore = @@ -150,8 +150,56 @@ class DefaultStressDataObject extends StressDataObject { } private readonly _globalObjects: Record = {}; - public get globalObjects(): Readonly>> { - return this._globalObjects; + public async globalObjects(): Promise>>> { + const globalObjects: Record> = { + ...this._globalObjects, + }; + const containerRuntime = // eslint-disable-next-line import/no-deprecated + this.context.containerRuntime as IContainerRuntimeWithResolveHandle_Deprecated; + for (const url of this.map.keys()) { + const resp = await containerRuntime.resolveHandle({ + url, + headers: { [RuntimeHeaders.wait]: false }, + }); + if (resp.status === 200) { + const maybeHandle: FluidObject | undefined = resp.value; + const handle = maybeHandle?.IFluidLoadable?.handle; + if (handle !== undefined) { + const entry = this.map.get(url); + switch (entry?.type) { + case "newAlias": + globalObjects[entry.id] = { + ...entry, + handle: undefined, + }; + break; + case "newBlob": + globalObjects[entry.id] = { + ...entry, + handle, + }; + break; + case "stressDataObject": + globalObjects[entry.id] = { + type: "stressDataObject", + id: entry.id, + dataStore: undefined, + handle, + stressDataObject: new LazyPromise(async () => { + const maybe = (await handle.get()) as + | FluidObject + | undefined; + assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); + return maybe.StressDataObject; + }), + }; + break; + default: + } + } + } + } + return globalObjects; } protected override async getDefaultStressDataObject(): Promise { @@ -164,10 +212,6 @@ class DefaultStressDataObject extends StressDataObject { this.map = SharedMap.create(this.runtime, "privateRoot"); this.map.bindToContext(); - this.map.on("valueChanged", (changed) => { - void this.processValue(changed.key); - }); - this.registerObject({ type: "stressDataObject", handle: this.handle, @@ -179,57 +223,6 @@ class DefaultStressDataObject extends StressDataObject { protected async initializingFromExisting(): Promise { this.map = (await this.runtime.getChannel("privateRoot")) as any as ISharedMap; - void Promise.resolve().then(async () => { - for (const url of this.map.keys()) { - await this.processValue(url); - } - }); - } - - private async processValue(url: string) { - const containerRuntime = // eslint-disable-next-line import/no-deprecated - this.context.containerRuntime as IContainerRuntimeWithResolveHandle_Deprecated; - const resp = await containerRuntime.resolveHandle({ - url, - headers: { [RuntimeHeaders.wait]: false }, - }); - if (resp.status === 200) { - const maybeHandle: FluidObject | undefined = resp.value; - const handle = maybeHandle?.IFluidLoadable?.handle; - if (handle !== undefined) { - const entry = this.map.get(url); - switch (entry?.type) { - case "newAlias": - this.registerObject({ - ...entry, - handle: undefined, - }); - break; - case "newBlob": - this.registerObject({ - ...entry, - handle, - }); - break; - case "stressDataObject": - this.registerObject({ - type: "stressDataObject", - id: entry.id, - dataStore: undefined, - handle, - stressDataObject: new LazyPromise(async () => { - const maybe = (await handle.get()) as - | FluidObject - | undefined; - assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); - return maybe.StressDataObject; - }), - }); - break; - default: - } - } - } } public registerObject(obj: ContainerObjects) { From d80f757ffc694d6f36b13e0a9953f65e183912df Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 16:47:49 -0800 Subject: [PATCH 39/54] fix options --- .../src/test/localServerStress.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index f9ec7e8f0fe1..0d3ac1dbc691 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -116,6 +116,6 @@ describe("Local Server Stress", () => { // replay: 98, // only: [98], saveFailures, - saveSuccesses, + // saveSuccesses, }); }); From 4d456d2937a8c01b573f68edd3dfb61550a69029 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 16:58:48 -0800 Subject: [PATCH 40/54] clean up --- .../src/ddsModels.ts | 2 +- .../src/localServerStressHarness.ts | 4 +-- .../src/stressDataObject.ts | 28 ++++++++----------- .../src/test/localServerStress.spec.ts | 3 +- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index c448fb336831..f0488de6d031 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -188,7 +188,7 @@ export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Clie v.type === "stressDataObject" ? v : undefined, )) { if (entry !== undefined) { - const stressDataObject = await entry?.stressDataObject; + const stressDataObject = entry?.stressDataObject; if (stressDataObject?.attached === true) { const channels = await stressDataObject.channels(); for (const channel of channels) { diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 87c9a178c59d..a30c66f9020e 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -792,7 +792,7 @@ function mixinClientSelection< Object.values(globalObjects).filter((v) => v.type === "stressDataObject"), ); assert(entry?.type === "stressDataObject"); - const datastore = await entry.stressDataObject; + const datastore = entry.stressDataObject; const channels = await datastore.StressDataObject.channels(); const channel = state.random.pick(channels); assert(channel !== undefined, "channel must exist"); @@ -817,7 +817,7 @@ function mixinClientSelection< const globalObjects = await client.entryPoint.globalObjects(); const entry = globalObjects[operation.datastoreId]; assert(entry?.type === "stressDataObject"); - const datastore = await entry.stressDataObject; + const datastore = entry.stressDataObject; const channels = await datastore.StressDataObject.channels(); const channel = channels.find((c) => c.id === operation.channelId); assert(channel !== undefined, "channel must exist"); diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index eaa16d94600d..c1b859e0ae4f 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -116,17 +116,15 @@ export class StressDataObject extends DataObject { : StressDataObject.factory.value.type, ) .then(async (dataStore) => { + const maybe: FluidObject | undefined = + await dataStore.entryPoint.get(); + assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); this.defaultStressObject.registerObject({ type: "stressDataObject", dataStore, handle: dataStore.entryPoint, id, - stressDataObject: new LazyPromise(async () => { - const maybe: FluidObject | undefined = - await dataStore.entryPoint.get(); - assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); - return maybe.StressDataObject; - }), + stressDataObject: maybe.StressDataObject, }); }); } @@ -138,7 +136,7 @@ export type ContainerObjects = id: `datastore-${number}`; dataStore: IDataStore | undefined; handle: IFluidHandle; - stressDataObject: LazyPromise; + stressDataObject: StressDataObject; } | { type: "newAlias"; id: `alias-${number}`; handle: undefined }; @@ -162,8 +160,8 @@ class DefaultStressDataObject extends StressDataObject { headers: { [RuntimeHeaders.wait]: false }, }); if (resp.status === 200) { - const maybeHandle: FluidObject | undefined = resp.value; - const handle = maybeHandle?.IFluidLoadable?.handle; + const maybe: FluidObject | undefined = resp.value; + const handle = maybe?.IFluidLoadable?.handle; if (handle !== undefined) { const entry = this.map.get(url); switch (entry?.type) { @@ -180,18 +178,14 @@ class DefaultStressDataObject extends StressDataObject { }; break; case "stressDataObject": + assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); + globalObjects[entry.id] = { type: "stressDataObject", id: entry.id, dataStore: undefined, handle, - stressDataObject: new LazyPromise(async () => { - const maybe = (await handle.get()) as - | FluidObject - | undefined; - assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); - return maybe.StressDataObject; - }), + stressDataObject: maybe.StressDataObject, }; break; default: @@ -217,7 +211,7 @@ class DefaultStressDataObject extends StressDataObject { handle: this.handle, id: `datastore-0`, dataStore: undefined, - stressDataObject: new LazyPromise(async () => this), + stressDataObject: this, }); } diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 0d3ac1dbc691..dcb7e269db2f 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -111,11 +111,12 @@ describe("Local Server Stress", () => { clientAddProbability: 0.1, }, reconnectProbability: 0.1, - skipMinimization: true, + // skipMinimization: true, // Uncomment to replay a particular seed. // replay: 98, // only: [98], saveFailures, // saveSuccesses, + skip: [47], }); }); From 421eb69f0f9e31f60cbe66a7cc0c008d0cc22d89 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 3 Feb 2025 17:14:14 -0800 Subject: [PATCH 41/54] reverts --- packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts | 2 +- packages/test/test-service-load/src/optionsMatrix.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts index 90885af4fd72..0fc738fc9e60 100644 --- a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts +++ b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts @@ -62,7 +62,7 @@ describe("SharedDirectory fuzz Create/Delete concentrated", () => { }); createDDSFuzzSuite( - { ...baseDirModel, workloadName: "default directory 1 with rebasing" }, + { ...model, workloadName: "default directory 1 with rebasing" }, { validationStrategy: { type: "random", diff --git a/packages/test/test-service-load/src/optionsMatrix.ts b/packages/test/test-service-load/src/optionsMatrix.ts index 7c46a178b61c..a12118ad2908 100644 --- a/packages/test/test-service-load/src/optionsMatrix.ts +++ b/packages/test/test-service-load/src/optionsMatrix.ts @@ -115,7 +115,6 @@ export function generateRuntimeOptions( enableRuntimeIdCompressor: ["on", undefined, "delayed"], enableGroupedBatching: [true, false], explicitSchemaControl: [true, false], - defaultGlobalIdGenerator: [], }; const pairwiseOptions = generatePairwiseOptions( From 833aad647c11501698ee10bd65d19cbf735d6d6b Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 4 Feb 2025 09:43:33 -0800 Subject: [PATCH 42/54] handle closed containers --- .../src/localServerStressHarness.ts | 115 +++++++++++------- .../src/test/localServerStress.spec.ts | 4 +- 2 files changed, 75 insertions(+), 44 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index a30c66f9020e..2355db861636 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -428,7 +428,7 @@ export interface LocalServerStressOptions { const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { defaultTestCount: defaultOptions.defaultTestCount, detachedStartOptions: { - numOpsBeforeAttach: 0, + numOpsBeforeAttach: 5, }, handleGenerationDisabled: true, emitter: new TypedEventEmitter(), @@ -687,51 +687,82 @@ function mixinSynchronization< const reducer: AsyncReducer = async (state, operation) => { // TODO: Only synchronize listed clients if specified if (isSynchronizeOp(operation)) { - const connectedClients = state.clients.filter( - (client) => client.container.connectionState !== ConnectionState.Disconnected, - ); + const connectedClients = state.clients.filter((client) => { + if (client.container.closed || client.container.disposed) { + throw new Error(`Client ${client.id} is closed`); + } + return client.container.connectionState !== ConnectionState.Disconnected; + }); - await Promise.all( - connectedClients.map( - async (c) => - new Promise((resolve) => - c.container.connectionState !== ConnectionState.Connected - ? c.container.once("connected", () => resolve()) - : resolve(), - ), - ), + const rejects = new Map void)[]>( + connectedClients.map((c) => [c, []]), ); - await Promise.all( - connectedClients.map( - async (c) => - new Promise((resolve) => - c.container.isDirty ? c.container.once("saved", () => resolve()) : resolve(), - ), - ), - ); - const maxSeq = Math.max( - ...connectedClients.map((c) => c.container.deltaManager.lastKnownSeqNumber), - ); + const cleanUps: (() => void)[] = []; + for (const c of connectedClients) { + const rejector = (err) => rejects.get(c)?.forEach((r) => r(err)); + c.container.once("closed", rejector); + c.container.once("disposed", rejector); + cleanUps.push(() => { + c.container.off("closed", rejector); + c.container.off("disposed", rejector); + }); + } + try { + await Promise.all( + connectedClients.map( + async (c) => + new Promise((resolve, reject) => { + if (c.container.connectionState !== ConnectionState.Connected) { + c.container.once("connected", () => resolve()); + rejects.get(c)?.push(reject); + } else { + resolve(); + } + }), + ), + ); - const makeOpHandler = (container: IContainer, resolve: () => void) => { - if (container.deltaManager.lastKnownSeqNumber < maxSeq) { - const handler = (msg) => { - if (msg.sequenceNumber >= maxSeq) { - container.off("op", handler); - resolve(); - } - }; - container.on("op", handler); - } else { - resolve(); - } - }; - await Promise.all( - connectedClients.map( - async (c) => new Promise((resolve) => makeOpHandler(c.container, resolve)), - ), - ); + await Promise.all( + connectedClients.map( + async (c) => + new Promise((resolve, reject) => { + if (c.container.isDirty) { + c.container.once("saved", () => resolve()); + rejects.get(c)?.push(reject); + } else { + resolve(); + } + }), + ), + ); + const maxSeq = Math.max( + ...connectedClients.map((c) => c.container.deltaManager.lastKnownSeqNumber), + ); + + const makeOpHandler = (c: Client, resolve: () => void, reject: () => void) => { + if (c.container.deltaManager.lastKnownSeqNumber < maxSeq) { + const handler = (msg) => { + if (msg.sequenceNumber >= maxSeq) { + c.container.off("op", handler); + resolve(); + } + }; + c.container.on("op", handler); + rejects.get(c)?.push(reject); + } else { + resolve(); + } + }; + await Promise.all( + connectedClients.map( + async (c) => + new Promise((resolve, reject) => makeOpHandler(c, resolve, reject)), + ), + ); + } finally { + cleanUps.forEach((f) => f()); + } if (connectedClients.length > 0) { const readonlyChannel = connectedClients[0]; diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index dcb7e269db2f..d4a32734ae24 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -114,9 +114,9 @@ describe("Local Server Stress", () => { // skipMinimization: true, // Uncomment to replay a particular seed. // replay: 98, - // only: [98], + // only: [99], saveFailures, // saveSuccesses, - skip: [47], + skip: [67, 99], }); }); From 82613c232bedcc88a19bfd598e5013bfa8b86bb1 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 4 Feb 2025 11:05:56 -0800 Subject: [PATCH 43/54] move from ids to tags --- .../src/ddsModels.ts | 34 +++---- .../src/localServerStressHarness.ts | 88 +++++++++-------- .../src/stressDataObject.ts | 97 +++++++++++-------- .../src/test/localServerStress.spec.ts | 35 ++----- 4 files changed, 128 insertions(+), 126 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index f0488de6d031..58d74ba0884e 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -86,8 +86,6 @@ export const ddsModelMap = generateSubModelMap( export interface DDSModelOp { type: "DDSModelOp"; - channelType: string; - channelId: string; op: unknown; } @@ -102,10 +100,10 @@ const createDDSClient = (channel: IChannel): DDSClient => { const covertLocalServerStateToDdsState = async ( state: LocalServerStressState, ): Promise> => { - const channels = await state.datastore.channels(); + const channels = await state.datastore.getChannels(); const allHandles = [ - ...channels.map((c) => ({ id: c.id, handle: c.handle })), - ...Object.values(await state.client.entryPoint.globalObjects()).filter( + ...channels.map((c) => ({ tag: c.id, handle: c.handle })), + ...(await state.client.entryPoint.getContainerObjects()).filter( (v) => v.handle !== undefined, ), ]; @@ -118,11 +116,11 @@ const covertLocalServerStateToDdsState = async ( random: { ...state.random, handle: () => { - const { id, handle } = state.random.pick(allHandles); + const { tag, handle } = state.random.pick(allHandles); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const realHandle = toFluidHandleInternal(handle!); return { - id, + tag, absolutePath: realHandle.absolutePath, get [fluidHandleSymbol]() { return realHandle[fluidHandleSymbol]; @@ -150,8 +148,6 @@ export const DDSModelOpGenerator: AsyncGenerator state, op, ) => { - const baseModel = ddsModelMap.get(op.channelType); + const baseModel = ddsModelMap.get(state.channel.attributes.type); assert(baseModel !== undefined, "must have base model"); - const channels = await state.datastore.channels(); - const globalObjects = await state.client.entryPoint.globalObjects(); + const channels = await state.datastore.getChannels(); + const globalObjects = await state.client.entryPoint.getContainerObjects(); const allHandles = [ - ...channels.map((c) => ({ id: c.id, handle: c.handle })), - ...Object.values(globalObjects).filter((v) => v.handle !== undefined), + ...channels.map((c) => ({ tag: c.id, handle: c.handle })), + ...globalObjects.filter((v) => v.handle !== undefined), ]; const subOp = JSON.parse(JSON.stringify(op.op), (key, value) => { - if (isObject(value) && "absolutePath" in value && "id" in value) { - const entry = allHandles.find((h) => h.id === value.id); + if (isObject(value) && "absolutePath" in value && "tag" in value) { + const entry = allHandles.find((h) => h.tag === value.tag); assert(entry !== undefined, "entry must exist"); return entry.handle; } @@ -184,16 +180,16 @@ export const DDSModelOpReducer: AsyncReducer export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { const buildChannelMap = async (client: Client) => { const channelMap = new Map(); - for (const entry of Object.values(await client.entryPoint.globalObjects()).map((v) => + for (const entry of (await client.entryPoint.getContainerObjects()).map((v) => v.type === "stressDataObject" ? v : undefined, )) { if (entry !== undefined) { const stressDataObject = entry?.stressDataObject; if (stressDataObject?.attached === true) { - const channels = await stressDataObject.channels(); + const channels = await stressDataObject.getChannels(); for (const channel of channels) { if (channel.isAttached()) { - channelMap.set(`${entry.id}/${channel.id}`, channel); + channelMap.set(`${entry.tag}/${channel.id}`, channel); } } } diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 2355db861636..6c4d180cbbf4 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -56,7 +56,11 @@ import { LocalCodeLoader } from "@fluidframework/test-utils/internal"; import { FuzzTestMinimizer } from "./minification.js"; import type { MinimizationTransform } from "./minification.js"; -import { createRuntimeFactory, StressDataObject } from "./stressDataObject.js"; +import { + createRuntimeFactory, + StressDataObject, + type DefaultStressDataObject, +} from "./stressDataObject.js"; const isOperationType = ( type: O["type"], @@ -65,8 +69,8 @@ const isOperationType = ( export interface Client { container: IContainer; - id: string; - entryPoint: StressDataObject; + tag: string; + entryPoint: DefaultStressDataObject; } /** @@ -88,9 +92,9 @@ export interface LocalServerStressState extends BaseFuzzTestState { * @internal */ interface SelectedClientSpec { - clientId: string; - datastoreId: string; - channelId: string; + clientTag: string; + datastoreTag: string; + channelTag: string; } /** @@ -112,7 +116,7 @@ interface Attach { */ interface AddClient { type: "addClient"; - id: string; + clientTag: string; url: string; } @@ -121,7 +125,7 @@ interface AddClient { */ interface RemoveClient { type: "removeClient"; - id: string; + clientTag: string; } /** @@ -471,7 +475,7 @@ function mixinAddRemoveClient< if (clients.length > options.numberOfClients && random.bool()) { return { type: "removeClient", - id: random.pick(clients).id, + clientTag: random.pick(clients).tag, } satisfies RemoveClient; } @@ -479,7 +483,7 @@ function mixinAddRemoveClient< return { type: "addClient", url: containerUrl, - id: makeFriendlyClientId(random), + clientTag: makeFriendlyClientTag(random), } satisfies AddClient; } } @@ -502,7 +506,7 @@ function mixinAddRemoveClient< const newClient = await loadClient( state.localDeltaConnectionServer, state.codeLoader, - op.id, + op.clientTag, op.url, ); state.clients.push(newClient); @@ -510,7 +514,7 @@ function mixinAddRemoveClient< } if (isOperationType("removeClient", op)) { const removed = state.clients.splice( - state.clients.findIndex((c) => c.id === op.id), + state.clients.findIndex((c) => c.tag === op.clientTag), 1, ); removed[0].container.dispose(); @@ -572,7 +576,7 @@ function mixinAttach { if (client.container.closed || client.container.disposed) { - throw new Error(`Client ${client.id} is closed`); + throw new Error(`Client ${client.tag} is closed`); } return client.container.connectionState !== ConnectionState.Disconnected; }); @@ -771,7 +775,7 @@ function mixinSynchronization< await model.validateConsistency(readonlyChannel, client); } catch (error: unknown) { if (error instanceof Error) { - error.message = `Comparing client ${readonlyChannel.id} vs client ${client.id}\n${error.message}`; + error.message = `Comparing client ${readonlyChannel.tag} vs client ${client.tag}\n${error.message}`; } throw error; } @@ -791,7 +795,7 @@ function mixinSynchronization< } const hasSelectedClientSpec = (op: unknown): op is SelectedClientSpec => - (op as SelectedClientSpec).clientId !== undefined; + (op as SelectedClientSpec).clientTag !== undefined; /** * Mixes in the ability to select a client to perform an operation on. @@ -818,13 +822,13 @@ function mixinClientSelection< // 2. Make it available to the subsequent reducer logic we're going to inject // (so that we can recover the channel from serialized data) const client = state.random.pick(state.clients); - const globalObjects = await client.entryPoint.globalObjects(); + const globalObjects = await client.entryPoint.getContainerObjects(); const entry = state.random.pick( - Object.values(globalObjects).filter((v) => v.type === "stressDataObject"), + globalObjects.filter((v) => v.type === "stressDataObject"), ); assert(entry?.type === "stressDataObject"); const datastore = entry.stressDataObject; - const channels = await datastore.StressDataObject.channels(); + const channels = await datastore.StressDataObject.getChannels(); const channel = state.random.pick(channels); assert(channel !== undefined, "channel must exist"); const baseOp = await runInStateWithClient(state, client, datastore, channel, async () => @@ -834,23 +838,23 @@ function mixinClientSelection< ? done : ({ ...baseOp, - clientId: client.id, - datastoreId: entry.id, - channelId: channel.id, + clientTag: client.tag, + datastoreTag: entry.tag, + channelTag: channel.id, } satisfies SelectedClientSpec); }; }; const reducer: AsyncReducer = async (state, operation) => { assert(hasSelectedClientSpec(operation), "operation should have been given a client"); - const client = state.clients.find((c) => c.id === operation.clientId); + const client = state.clients.find((c) => c.tag === operation.clientTag); assert(client !== undefined); - const globalObjects = await client.entryPoint.globalObjects(); - const entry = globalObjects[operation.datastoreId]; + const globalObjects = await client.entryPoint.getContainerObjects(); + const entry = globalObjects.find((e) => e.tag === operation.datastoreTag); assert(entry?.type === "stressDataObject"); const datastore = entry.stressDataObject; - const channels = await datastore.StressDataObject.channels(); - const channel = channels.find((c) => c.id === operation.channelId); + const channels = await datastore.StressDataObject.getChannels(); + const channel = channels.find((c) => c.id === operation.channelTag); assert(channel !== undefined, "channel must exist"); await runInStateWithClient(state, client, datastore, channel, async () => { await model.reducer(state, operation as TOperation); @@ -906,7 +910,7 @@ async function createDetachedClient( localDeltaConnectionServer: ILocalDeltaConnectionServer, codeLoader: ICodeDetailsLoader, codeDetails: IFluidCodeDetails, - id: string, + tag: string, ): Promise { const container = await createDetachedContainer({ codeLoader, @@ -915,13 +919,14 @@ async function createDetachedClient( codeDetails, }); - const maybe: FluidObject | undefined = await container.getEntryPoint(); - assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); + const maybe: FluidObject | undefined = + await container.getEntryPoint(); + assert(maybe.DefaultStressDataObject !== undefined, "must be DefaultStressDataObject"); const newClient: Client = { container, - id, - entryPoint: maybe.StressDataObject, + tag, + entryPoint: maybe.DefaultStressDataObject, }; return newClient; } @@ -929,7 +934,7 @@ async function createDetachedClient( async function loadClient( localDeltaConnectionServer: ILocalDeltaConnectionServer, codeLoader: ICodeDetailsLoader, - id: string, + tag: string, url: string, ): Promise { const container = await loadExistingContainer({ @@ -939,13 +944,14 @@ async function loadClient( codeLoader, }); - const maybe: FluidObject | undefined = await container.getEntryPoint(); - assert(maybe.StressDataObject !== undefined, "must be StressDataObject"); + const maybe: FluidObject | undefined = + await container.getEntryPoint(); + assert(maybe.DefaultStressDataObject !== undefined, "must be DefaultStressDataObject"); return { container, - id, - entryPoint: maybe.StressDataObject, + tag, + entryPoint: maybe.DefaultStressDataObject, }; } @@ -955,7 +961,7 @@ async function loadClient( * about client "3e8a621a-7b35-414b-897f-8795962fb415". */ let clientCount = 0; -function makeFriendlyClientId(random: IRandom): string { +function makeFriendlyClientTag(random: IRandom): string { const index = clientCount++; return index < 26 ? String.fromCodePoint(index + 65) : random.uuid4(); } @@ -984,7 +990,7 @@ async function runTestForSeed( localDeltaConnectionServer, codeLoader, codeDetails, - startDetached ? makeFriendlyClientId(random) : "original", + startDetached ? makeFriendlyClientTag(random) : "original", ); const clients: Client[] = [initialClient]; let containerUrl: string | undefined; @@ -998,7 +1004,7 @@ async function runTestForSeed( loadClient( localDeltaConnectionServer, codeLoader, - makeFriendlyClientId(random), + makeFriendlyClientTag(random), url, ), ), @@ -1255,7 +1261,7 @@ export function mixinReconnect< const baseGenerator = model.generatorFactory(); return async (state): Promise => { if (!state.isDetached && state.random.bool(options.reconnectProbability)) { - const client = state.clients.find((c) => c.id === state.client.id); + const client = state.clients.find((c) => c.tag === state.client.tag); assert(client !== undefined); return { type: "changeConnectionState", diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index c1b859e0ae4f..fec5ef5983ee 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -30,6 +30,24 @@ import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import { ddsModelMap } from "./ddsModels"; import { makeUnreachableCodePathProxy } from "./localServerStressHarness"; +export interface UploadBlob { + type: "uploadBlob"; + tag: `blob-${number}`; +} +export interface CreateDataStore { + type: "createDataStore"; + asChild: boolean; + tag: `datastore-${number}`; +} + +export interface CreateChannel { + type: "createChannel"; + channelType: string; + tag: `channel-${number}`; +} + +export type StressDataObjectOperations = UploadBlob | CreateDataStore | CreateChannel; + export class StressDataObject extends DataObject { public static readonly factory = new Lazy(() => { const factory = new DataObjectFactory( @@ -49,9 +67,6 @@ export class StressDataObject extends DataObject { private defaultStressObject: DefaultStressDataObject = makeUnreachableCodePathProxy( "defaultStressDataObject", ); - public async globalObjects() { - return this.defaultStressObject.globalObjects(); - } protected async getDefaultStressDataObject(): Promise { const defaultDataStore = await this.context.containerRuntime.getAliasedDataStoreEntryPoint("default"); @@ -70,7 +85,7 @@ export class StressDataObject extends DataObject { this.channelNameMap.set("root", this.root.attributes.type); } - public async channels() { + public async getChannels() { const channels: IChannel[] = []; for (const [name] of this.channelNameMap.entries()) { const channel = await this.runtime.getChannel(name).catch(() => undefined); @@ -93,22 +108,22 @@ export class StressDataObject extends DataObject { return this.runtime.attachState === AttachState.Attached; } - public uploadBlob(id: `blob-${number}`, contents: string) { + public uploadBlob(tag: `blob-${number}`, contents: string) { void this.runtime.uploadBlob(stringToBuffer(contents, "utf-8")).then((handle) => - this.defaultStressObject.registerObject({ + this.defaultStressObject.registerLocallyCreatedObject({ type: "newBlob", handle, - id, + tag, }), ); } - public createChannel(id: `channel-${number}`, type: string) { - this.runtime.createChannel(id, type); - this.channelNameMap.set(id, type); + public createChannel(tag: `channel-${number}`, type: string) { + this.runtime.createChannel(tag, type); + this.channelNameMap.set(tag, type); } - public createDataStore(id: `datastore-${number}`, asChild: boolean) { + public createDataStore(tag: `datastore-${number}`, asChild: boolean) { void this.context.containerRuntime .createDataStore( asChild @@ -119,42 +134,40 @@ export class StressDataObject extends DataObject { const maybe: FluidObject | undefined = await dataStore.entryPoint.get(); assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); - this.defaultStressObject.registerObject({ + this.defaultStressObject.registerLocallyCreatedObject({ type: "stressDataObject", dataStore, handle: dataStore.entryPoint, - id, + tag, stressDataObject: maybe.StressDataObject, }); }); } } export type ContainerObjects = - | { type: "newBlob"; handle: IFluidHandle; id: `blob-${number}` } + | { type: "newBlob"; handle: IFluidHandle; tag: `blob-${number}` } | { type: "stressDataObject"; - id: `datastore-${number}`; + tag: `datastore-${number}`; dataStore: IDataStore | undefined; handle: IFluidHandle; stressDataObject: StressDataObject; } - | { type: "newAlias"; id: `alias-${number}`; handle: undefined }; + | { type: "newAlias"; tag: `alias-${number}`; handle: undefined }; -class DefaultStressDataObject extends StressDataObject { +export class DefaultStressDataObject extends StressDataObject { public static readonly alias = "default"; public get DefaultStressDataObject() { return this; } - private readonly _globalObjects: Record = {}; - public async globalObjects(): Promise>>> { - const globalObjects: Record> = { - ...this._globalObjects, - }; + private readonly _locallyCreatedObjects: ContainerObjects[] = []; + public async getContainerObjects(): Promise[]> { + const globalObjects: Readonly[] = [...this._locallyCreatedObjects]; const containerRuntime = // eslint-disable-next-line import/no-deprecated this.context.containerRuntime as IContainerRuntimeWithResolveHandle_Deprecated; - for (const url of this.map.keys()) { + for (const url of this.containerObjectMap.keys()) { const resp = await containerRuntime.resolveHandle({ url, headers: { [RuntimeHeaders.wait]: false }, @@ -163,30 +176,30 @@ class DefaultStressDataObject extends StressDataObject { const maybe: FluidObject | undefined = resp.value; const handle = maybe?.IFluidLoadable?.handle; if (handle !== undefined) { - const entry = this.map.get(url); + const entry = this.containerObjectMap.get(url); switch (entry?.type) { case "newAlias": - globalObjects[entry.id] = { + globalObjects.push({ ...entry, handle: undefined, - }; + }); break; case "newBlob": - globalObjects[entry.id] = { + globalObjects.push({ ...entry, handle, - }; + }); break; case "stressDataObject": assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); - globalObjects[entry.id] = { + globalObjects.push({ type: "stressDataObject", - id: entry.id, + tag: entry.tag, dataStore: undefined, handle, stressDataObject: maybe.StressDataObject, - }; + }); break; default: } @@ -200,33 +213,35 @@ class DefaultStressDataObject extends StressDataObject { return this; } - private map: ISharedMap = makeUnreachableCodePathProxy("map"); + private containerObjectMap: ISharedMap = makeUnreachableCodePathProxy("containerObjectMap"); protected async initializingFirstTime(props?: any): Promise { await super.initializingFirstTime(props); - this.map = SharedMap.create(this.runtime, "privateRoot"); - this.map.bindToContext(); + this.containerObjectMap = SharedMap.create(this.runtime, "containerObjectMap"); + this.containerObjectMap.bindToContext(); - this.registerObject({ + this.registerLocallyCreatedObject({ type: "stressDataObject", handle: this.handle, - id: `datastore-0`, + tag: `datastore-0`, dataStore: undefined, stressDataObject: this, }); } protected async initializingFromExisting(): Promise { - this.map = (await this.runtime.getChannel("privateRoot")) as any as ISharedMap; + this.containerObjectMap = (await this.runtime.getChannel( + "containerObjectMap", + )) as any as ISharedMap; } - public registerObject(obj: ContainerObjects) { + public registerLocallyCreatedObject(obj: ContainerObjects) { if (obj.handle) { const handle = toFluidHandleInternal(obj.handle); - if (this.map.get(handle.absolutePath) === undefined) { - this.map.set(handle.absolutePath, { id: obj.id, type: obj.type }); + if (this.containerObjectMap.get(handle.absolutePath) === undefined) { + this.containerObjectMap.set(handle.absolutePath, { tag: obj.tag, type: obj.type }); } } - this._globalObjects[obj.id] = obj; + this._locallyCreatedObjects.push(obj); } } diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index d4a32734ae24..91016887c6ba 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -24,41 +24,26 @@ import { LocalServerStressModel, type LocalServerStressState, } from "../localServerStressHarness"; +import type { StressDataObjectOperations } from "../stressDataObject.js"; import { _dirname } from "./dirname.cjs"; -interface UploadBlob { - type: "uploadBlob"; - id: `blob-${number}`; -} -interface CreateDataStore { - type: "createDataStore"; - asChild: boolean; - id: `datastore-${number}`; -} - -interface CreateChannel { - type: "createChannel"; - channelType: string; - id: `channel-${number}`; -} - -type StressOperations = UploadBlob | CreateDataStore | CreateChannel | DDSModelOp; +type StressOperations = StressDataObjectOperations | DDSModelOp; const reducer = combineReducersAsync({ createDataStore: async (state, op) => { - state.datastore.createDataStore(op.id, op.asChild); + state.datastore.createDataStore(op.tag, op.asChild); }, createChannel: async (state, op) => { - state.datastore.createChannel(op.id, op.channelType); + state.datastore.createChannel(op.tag, op.channelType); }, uploadBlob: async (state, op) => { - state.datastore.uploadBlob(op.id, state.random.string(state.random.integer(1, 16))); + state.datastore.uploadBlob(op.tag, state.random.string(state.random.integer(1, 16))); }, DDSModelOp: DDSModelOpReducer, }); -let id = 0; +let tag = 0; function makeGenerator(): AsyncGenerator { const asyncGenerator = createWeightedAsyncGenerator< StressOperations, @@ -68,14 +53,14 @@ function makeGenerator(): AsyncGenerator ({ type: "createDataStore", asChild: state.random.bool(), - id: `datastore-${++id}`, + tag: `datastore-${++tag}`, }), 1, ], [ async (state) => ({ type: "uploadBlob", - id: `blob-${++id}`, + tag: `blob-${++tag}`, }), 10, ], @@ -83,7 +68,7 @@ function makeGenerator(): AsyncGenerator ({ type: "createChannel", channelType: state.random.pick([...ddsModelMap.keys()]), - id: `channel-${++id}`, + tag: `channel-${++tag}`, }), 5, ], @@ -117,6 +102,6 @@ describe("Local Server Stress", () => { // only: [99], saveFailures, // saveSuccesses, - skip: [67, 99], + skip: [], }); }); From d866256cc4114203d683a6a190b71e15d4d92f0f Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 4 Feb 2025 11:42:32 -0800 Subject: [PATCH 44/54] add tagging to state --- .../src/localServerStressHarness.ts | 49 +++---- .../src/test/localServerStress.spec.ts | 9 +- .../src/test/results/default/67.json | 130 ++++++++++++++++++ .../src/test/results/default/77.json | 24 ++++ .../src/test/results/default/99.json | 28 ++++ 5 files changed, 203 insertions(+), 37 deletions(-) create mode 100644 packages/test/local-server-stress-tests/src/test/results/default/67.json create mode 100644 packages/test/local-server-stress-tests/src/test/results/default/77.json create mode 100644 packages/test/local-server-stress-tests/src/test/results/default/99.json diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 6c4d180cbbf4..5298eda53969 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -69,7 +69,7 @@ const isOperationType = ( export interface Client { container: IContainer; - tag: string; + tag: `client-${number}`; entryPoint: DefaultStressDataObject; } @@ -86,15 +86,16 @@ export interface LocalServerStressState extends BaseFuzzTestState { datastore: StressDataObject; channel: IChannel; isDetached: boolean; + tag(prefix: T): `${T}-${number}`; } /** * @internal */ interface SelectedClientSpec { - clientTag: string; - datastoreTag: string; - channelTag: string; + clientTag: `client-${number}`; + datastoreTag: `datastore-${number}`; + channelTag: `channel-${number}`; } /** @@ -116,7 +117,7 @@ interface Attach { */ interface AddClient { type: "addClient"; - clientTag: string; + clientTag: `client-${number}`; url: string; } @@ -125,7 +126,7 @@ interface AddClient { */ interface RemoveClient { type: "removeClient"; - clientTag: string; + clientTag: `client-${number}`; } /** @@ -483,7 +484,7 @@ function mixinAddRemoveClient< return { type: "addClient", url: containerUrl, - clientTag: makeFriendlyClientTag(random), + clientTag: state.tag("client"), } satisfies AddClient; } } @@ -576,7 +577,7 @@ function mixinAttach { const container = await createDetachedContainer({ codeLoader, @@ -934,7 +935,7 @@ async function createDetachedClient( async function loadClient( localDeltaConnectionServer: ILocalDeltaConnectionServer, codeLoader: ICodeDetailsLoader, - tag: string, + tag: `client-${number}`, url: string, ): Promise { const container = await loadExistingContainer({ @@ -954,18 +955,6 @@ async function loadClient( entryPoint: maybe.DefaultStressDataObject, }; } - -/** - * Gets a friendly ID for a client based on its index in the client list. - * This exists purely for easier debugging--reasoning about client "A" is easier than reasoning - * about client "3e8a621a-7b35-414b-897f-8795962fb415". - */ -let clientCount = 0; -function makeFriendlyClientTag(random: IRandom): string { - const index = clientCount++; - return index < 26 ? String.fromCodePoint(index + 65) : random.uuid4(); -} - /** * Runs the provided DDS fuzz model. All functionality is already assumed to be mixed in. * @privateRemarks This is currently file-exported for testing purposes, but it could be reasonable to @@ -985,12 +974,13 @@ async function runTestForSeed( package: "local-server-stress-tests", }; const codeLoader = new LocalCodeLoader([[codeDetails, createRuntimeFactory()]]); - clientCount = 0; + let tagCount = 0; + const tag: LocalServerStressState["tag"] = (prefix) => `${prefix}-${tagCount++}`; const initialClient = await createDetachedClient( localDeltaConnectionServer, codeLoader, codeDetails, - startDetached ? makeFriendlyClientTag(random) : "original", + tag("client"), ); const clients: Client[] = [initialClient]; let containerUrl: string | undefined; @@ -1001,17 +991,11 @@ async function runTestForSeed( clients.push( ...(await Promise.all( Array.from({ length: options.numberOfClients - 1 }, async (_, i) => - loadClient( - localDeltaConnectionServer, - codeLoader, - makeFriendlyClientTag(random), - url, - ), + loadClient(localDeltaConnectionServer, codeLoader, tag("client"), url), ), )), ); } - const initialState: LocalServerStressState = { clients, localDeltaConnectionServer, @@ -1022,6 +1006,7 @@ async function runTestForSeed( channel: makeUnreachableCodePathProxy("channel"), isDetached: startDetached, containerUrl, + tag, }; options.emitter.emit("testStart", initialState); diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 91016887c6ba..9e710f078190 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -43,7 +43,6 @@ const reducer = combineReducersAsync({ DDSModelOp: DDSModelOpReducer, }); -let tag = 0; function makeGenerator(): AsyncGenerator { const asyncGenerator = createWeightedAsyncGenerator< StressOperations, @@ -53,14 +52,14 @@ function makeGenerator(): AsyncGenerator ({ type: "createDataStore", asChild: state.random.bool(), - tag: `datastore-${++tag}`, + tag: state.tag("datastore"), }), 1, ], [ async (state) => ({ type: "uploadBlob", - tag: `blob-${++tag}`, + tag: state.tag("blob"), }), 10, ], @@ -68,7 +67,7 @@ function makeGenerator(): AsyncGenerator ({ type: "createChannel", channelType: state.random.pick([...ddsModelMap.keys()]), - tag: `channel-${++tag}`, + tag: state.tag("channel"), }), 5, ], @@ -102,6 +101,6 @@ describe("Local Server Stress", () => { // only: [99], saveFailures, // saveSuccesses, - skip: [], + skip: [67, 77, 99], }); }); diff --git a/packages/test/local-server-stress-tests/src/test/results/default/67.json b/packages/test/local-server-stress-tests/src/test/results/default/67.json new file mode 100644 index 000000000000..18e4f2e39da7 --- /dev/null +++ b/packages/test/local-server-stress-tests/src/test/results/default/67.json @@ -0,0 +1,130 @@ +[ + { + "type": "DDSModelOp", + "op": { + "type": "set", + "key": "prop2", + "path": "/", + "value": "E4v" + }, + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "attach" + }, + { + "type": "DDSModelOp", + "op": { + "type": "clear", + "path": "/" + }, + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "DDSModelOp", + "op": { + "type": "createSubDirectory", + "name": "dir3", + "path": "/" + }, + "clientTag": "client-3", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "DDSModelOp", + "op": { + "type": "createSubDirectory", + "name": "dir3", + "path": "/" + }, + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "DDSModelOp", + "op": { + "type": "set", + "key": "prop3", + "path": "/dir3", + "value": { + "tag": "datastore-0", + "absolutePath": "/a6a4db21-a234-405b-855f-5d85f63ed3fb", + "isAttached": true + } + }, + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "DDSModelOp", + "op": { + "type": "deleteSubDirectory", + "name": "dir3", + "path": "/" + }, + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "DDSModelOp", + "op": { + "type": "createSubDirectory", + "name": "dir3", + "path": "/" + }, + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "DDSModelOp", + "op": { + "type": "set", + "key": "prop3", + "path": "/dir3", + "value": { + "tag": "root", + "absolutePath": "/a6a4db21-a234-405b-855f-5d85f63ed3fb/root", + "isAttached": true + } + }, + "clientTag": "client-3", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "DDSModelOp", + "op": { + "type": "set", + "key": "prop3", + "path": "/dir3", + "value": "" + }, + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "DDSModelOp", + "op": { + "type": "set", + "key": "prop3", + "path": "/dir3", + "value": "UKTr" + }, + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "synchronize" + } +] \ No newline at end of file diff --git a/packages/test/local-server-stress-tests/src/test/results/default/77.json b/packages/test/local-server-stress-tests/src/test/results/default/77.json new file mode 100644 index 000000000000..6c12ba5816df --- /dev/null +++ b/packages/test/local-server-stress-tests/src/test/results/default/77.json @@ -0,0 +1,24 @@ +[ + { + "type": "createDataStore", + "asChild": false, + "tag": "datastore-1", + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "attach" + }, + { + "type": "DDSModelOp", + "op": { + "type": "createSubDirectory", + "name": "dir3", + "path": "/dir1" + }, + "clientTag": "client-4", + "datastoreTag": "datastore-0", + "channelTag": "root" + } +] \ No newline at end of file diff --git a/packages/test/local-server-stress-tests/src/test/results/default/99.json b/packages/test/local-server-stress-tests/src/test/results/default/99.json new file mode 100644 index 000000000000..1c8ab90f3fbf --- /dev/null +++ b/packages/test/local-server-stress-tests/src/test/results/default/99.json @@ -0,0 +1,28 @@ +[ + { + "type": "uploadBlob", + "tag": "blob-1", + "clientTag": "client-0", + "datastoreTag": "datastore-0", + "channelTag": "root" + }, + { + "type": "attach" + }, + { + "type": "DDSModelOp", + "op": { + "type": "set", + "key": "prop2", + "path": "/", + "value": { + "tag": "datastore-0", + "absolutePath": "/1e9cdc24-d0ed-41fc-a421-8498a3e2da50", + "isAttached": true + } + }, + "clientTag": "client-4", + "datastoreTag": "datastore-0", + "channelTag": "root" + } +] \ No newline at end of file From 80475fd84ae81e0738a94a8a3f2808480973e35f Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 4 Feb 2025 13:00:10 -0800 Subject: [PATCH 45/54] remove results --- .../src/test/results/default/67.json | 130 ------------------ .../src/test/results/default/77.json | 24 ---- .../src/test/results/default/99.json | 28 ---- 3 files changed, 182 deletions(-) delete mode 100644 packages/test/local-server-stress-tests/src/test/results/default/67.json delete mode 100644 packages/test/local-server-stress-tests/src/test/results/default/77.json delete mode 100644 packages/test/local-server-stress-tests/src/test/results/default/99.json diff --git a/packages/test/local-server-stress-tests/src/test/results/default/67.json b/packages/test/local-server-stress-tests/src/test/results/default/67.json deleted file mode 100644 index 18e4f2e39da7..000000000000 --- a/packages/test/local-server-stress-tests/src/test/results/default/67.json +++ /dev/null @@ -1,130 +0,0 @@ -[ - { - "type": "DDSModelOp", - "op": { - "type": "set", - "key": "prop2", - "path": "/", - "value": "E4v" - }, - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "attach" - }, - { - "type": "DDSModelOp", - "op": { - "type": "clear", - "path": "/" - }, - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "DDSModelOp", - "op": { - "type": "createSubDirectory", - "name": "dir3", - "path": "/" - }, - "clientTag": "client-3", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "DDSModelOp", - "op": { - "type": "createSubDirectory", - "name": "dir3", - "path": "/" - }, - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "DDSModelOp", - "op": { - "type": "set", - "key": "prop3", - "path": "/dir3", - "value": { - "tag": "datastore-0", - "absolutePath": "/a6a4db21-a234-405b-855f-5d85f63ed3fb", - "isAttached": true - } - }, - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "DDSModelOp", - "op": { - "type": "deleteSubDirectory", - "name": "dir3", - "path": "/" - }, - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "DDSModelOp", - "op": { - "type": "createSubDirectory", - "name": "dir3", - "path": "/" - }, - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "DDSModelOp", - "op": { - "type": "set", - "key": "prop3", - "path": "/dir3", - "value": { - "tag": "root", - "absolutePath": "/a6a4db21-a234-405b-855f-5d85f63ed3fb/root", - "isAttached": true - } - }, - "clientTag": "client-3", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "DDSModelOp", - "op": { - "type": "set", - "key": "prop3", - "path": "/dir3", - "value": "" - }, - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "DDSModelOp", - "op": { - "type": "set", - "key": "prop3", - "path": "/dir3", - "value": "UKTr" - }, - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "synchronize" - } -] \ No newline at end of file diff --git a/packages/test/local-server-stress-tests/src/test/results/default/77.json b/packages/test/local-server-stress-tests/src/test/results/default/77.json deleted file mode 100644 index 6c12ba5816df..000000000000 --- a/packages/test/local-server-stress-tests/src/test/results/default/77.json +++ /dev/null @@ -1,24 +0,0 @@ -[ - { - "type": "createDataStore", - "asChild": false, - "tag": "datastore-1", - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "attach" - }, - { - "type": "DDSModelOp", - "op": { - "type": "createSubDirectory", - "name": "dir3", - "path": "/dir1" - }, - "clientTag": "client-4", - "datastoreTag": "datastore-0", - "channelTag": "root" - } -] \ No newline at end of file diff --git a/packages/test/local-server-stress-tests/src/test/results/default/99.json b/packages/test/local-server-stress-tests/src/test/results/default/99.json deleted file mode 100644 index 1c8ab90f3fbf..000000000000 --- a/packages/test/local-server-stress-tests/src/test/results/default/99.json +++ /dev/null @@ -1,28 +0,0 @@ -[ - { - "type": "uploadBlob", - "tag": "blob-1", - "clientTag": "client-0", - "datastoreTag": "datastore-0", - "channelTag": "root" - }, - { - "type": "attach" - }, - { - "type": "DDSModelOp", - "op": { - "type": "set", - "key": "prop2", - "path": "/", - "value": { - "tag": "datastore-0", - "absolutePath": "/1e9cdc24-d0ed-41fc-a421-8498a3e2da50", - "isAttached": true - } - }, - "clientTag": "client-4", - "datastoreTag": "datastore-0", - "channelTag": "root" - } -] \ No newline at end of file From f6838a0f24f4659d6ad861cd7c863a509c3ad3e5 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 4 Feb 2025 17:11:02 -0800 Subject: [PATCH 46/54] add comments --- .../test/mocha/directoryEquivalenceUtils.ts | 2 +- .../src/stressDataObject.ts | 42 +++++++++++++------ .../src/test/localServerStress.spec.ts | 4 +- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts b/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts index 101611b92949..23bea880b1da 100644 --- a/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts +++ b/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts @@ -42,7 +42,7 @@ async function assertEventualConsistencyCore( for (const key of first.keys()) { const firstVal: unknown = first.get(key); const secondVal: unknown = second.get(key); - if (isObject(firstVal) === true || isObject(secondVal)) { + if (isObject(firstVal) === true) { assert( isObject(secondVal), `Values differ at key ${key}: first is an object, second is not`, diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index fec5ef5983ee..babbf3332739 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -24,7 +24,6 @@ import type { import { assert, Lazy, LazyPromise } from "@fluidframework/core-utils/internal"; import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import { ISharedMap, SharedMap } from "@fluidframework/map/internal"; -import type { IDataStore } from "@fluidframework/runtime-definitions/internal"; import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import { ddsModelMap } from "./ddsModels"; @@ -78,6 +77,11 @@ export class StressDataObject extends DataObject { return maybe.DefaultStressDataObject; } + /** + * this map is special, and doesn't participate in stress. it hold data + * about the name of channels which have been created. these created channel + * may or may not be attached and be available + */ private channelNameMap: ISharedMap = makeUnreachableCodePathProxy("channelNameMap"); protected async initializingFirstTime(props?: any): Promise { this.channelNameMap = SharedMap.create(this.runtime, "channelNameMap"); @@ -88,6 +92,12 @@ export class StressDataObject extends DataObject { public async getChannels() { const channels: IChannel[] = []; for (const [name] of this.channelNameMap.entries()) { + // similar to container objects, the entries in this map + // can appear before the underlying channel is attached, + // so getting the channel can fail, and we need to try + // to get all channel each time, as we have no way to + // observer when a channel moves from detached to attached, + // especially on remove clients/ const channel = await this.runtime.getChannel(name).catch(() => undefined); if (channel !== undefined) { channels.push(channel); @@ -136,7 +146,6 @@ export class StressDataObject extends DataObject { assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); this.defaultStressObject.registerLocallyCreatedObject({ type: "stressDataObject", - dataStore, handle: dataStore.entryPoint, tag, stressDataObject: maybe.StressDataObject, @@ -149,11 +158,9 @@ export type ContainerObjects = | { type: "stressDataObject"; tag: `datastore-${number}`; - dataStore: IDataStore | undefined; handle: IFluidHandle; stressDataObject: StressDataObject; - } - | { type: "newAlias"; tag: `alias-${number}`; handle: undefined }; + }; export class DefaultStressDataObject extends StressDataObject { public static readonly alias = "default"; @@ -162,12 +169,24 @@ export class DefaultStressDataObject extends StressDataObject { return this; } + /** + * these are object created in memory by this instance of the datastore, they + * will also be in these the containerObjectMap, but are not necessarily usable + * as they could be detached, in which can only this instance can access them. + */ private readonly _locallyCreatedObjects: ContainerObjects[] = []; public async getContainerObjects(): Promise[]> { const globalObjects: Readonly[] = [...this._locallyCreatedObjects]; const containerRuntime = // eslint-disable-next-line import/no-deprecated this.context.containerRuntime as IContainerRuntimeWithResolveHandle_Deprecated; for (const url of this.containerObjectMap.keys()) { + // the container objects map will see things before they are attached, + // so they may not be available to remote clients yet. + // Additionally, there is no way to observe when an + // object goes from detached to attached. + // Due to the both the above, we need to always try + // to resolve each object, and just ignore those which can't + // be found. const resp = await containerRuntime.resolveHandle({ url, headers: { [RuntimeHeaders.wait]: false }, @@ -178,12 +197,6 @@ export class DefaultStressDataObject extends StressDataObject { if (handle !== undefined) { const entry = this.containerObjectMap.get(url); switch (entry?.type) { - case "newAlias": - globalObjects.push({ - ...entry, - handle: undefined, - }); - break; case "newBlob": globalObjects.push({ ...entry, @@ -196,7 +209,6 @@ export class DefaultStressDataObject extends StressDataObject { globalObjects.push({ type: "stressDataObject", tag: entry.tag, - dataStore: undefined, handle, stressDataObject: maybe.StressDataObject, }); @@ -213,6 +225,11 @@ export class DefaultStressDataObject extends StressDataObject { return this; } + /** + * this map is special, and doesn't participate in stress. it holds data + * about the name of container objects which have been created. these created objects + * may or may not be attached and be available + */ private containerObjectMap: ISharedMap = makeUnreachableCodePathProxy("containerObjectMap"); protected async initializingFirstTime(props?: any): Promise { await super.initializingFirstTime(props); @@ -223,7 +240,6 @@ export class DefaultStressDataObject extends StressDataObject { type: "stressDataObject", handle: this.handle, tag: `datastore-0`, - dataStore: undefined, stressDataObject: this, }); } diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 9e710f078190..f253b4ccd019 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -62,6 +62,8 @@ function makeGenerator(): AsyncGenerator !state.isDetached, ], [ async (state) => ({ @@ -101,6 +103,6 @@ describe("Local Server Stress", () => { // only: [99], saveFailures, // saveSuccesses, - skip: [67, 77, 99], + skip: [67, 77], }); }); From 317913b68ac87f3a4781329ce675b96e058be5cc Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 4 Feb 2025 18:39:48 -0800 Subject: [PATCH 47/54] comments and lints --- .../test/local-server-stress-tests/src/ddsModels.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 58d74ba0884e..3fe231190b8b 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -117,8 +117,7 @@ const covertLocalServerStateToDdsState = async ( ...state.random, handle: () => { const { tag, handle } = state.random.pick(allHandles); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const realHandle = toFluidHandleInternal(handle!); + const realHandle = toFluidHandleInternal(handle); return { tag, absolutePath: realHandle.absolutePath, @@ -165,13 +164,16 @@ export const DDSModelOpReducer: AsyncReducer ...globalObjects.filter((v) => v.handle !== undefined), ]; - const subOp = JSON.parse(JSON.stringify(op.op), (key, value) => { + // we always serialize and then deserialize withe a handle look + // up, as this ensure we all do the same thing, regardless of if + // we are replaying from a file with serialized generated operations, or + // running live with in-memory generated operations. + const subOp = JSON.parse(JSON.stringify(op.op), (key, value: unknown) => { if (isObject(value) && "absolutePath" in value && "tag" in value) { const entry = allHandles.find((h) => h.tag === value.tag); assert(entry !== undefined, "entry must exist"); return entry.handle; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }); await baseModel.reducer(await covertLocalServerStateToDdsState(state), subOp); From 2376ca43a74dcef4f8f762c5197db87b1d97f3e9 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 18 Feb 2025 14:16:47 -0800 Subject: [PATCH 48/54] some PR feedback --- .../local-server-stress-tests/.eslintrc.cjs | 9 --- .../local-server-stress-tests/package.json | 2 +- .../src/ddsModels.ts | 13 ++++ .../src/localServerStressHarness.ts | 43 ++++++----- .../src/minification.ts | 2 +- .../src/stressDataObject.ts | 75 ++++++++++--------- .../src/test/localServerStress.spec.ts | 10 +-- 7 files changed, 83 insertions(+), 71 deletions(-) diff --git a/packages/test/local-server-stress-tests/.eslintrc.cjs b/packages/test/local-server-stress-tests/.eslintrc.cjs index 623b6a0c58e3..e9e7183c15eb 100644 --- a/packages/test/local-server-stress-tests/.eslintrc.cjs +++ b/packages/test/local-server-stress-tests/.eslintrc.cjs @@ -9,17 +9,8 @@ module.exports = { "prettier", ], rules: { - "@typescript-eslint/strict-boolean-expressions": "off", // requires strictNullChecks=true in tsconfig "import/no-nodejs-modules": "off", "@fluid-internal/fluid/no-unchecked-record-access": "warn", - "import/no-extraneous-dependencies": [ - "error", - { - // This package is only used to run its tests. It's ok for the src/utils.ts to import from devDependencies, in - // addition to the test files - devDependencies: ["src/test/**"], - }, - ], }, parserOptions: { project: ["./src/test/tsconfig.json"], diff --git a/packages/test/local-server-stress-tests/package.json b/packages/test/local-server-stress-tests/package.json index 73f1ba8e9769..ce9dc706e433 100644 --- a/packages/test/local-server-stress-tests/package.json +++ b/packages/test/local-server-stress-tests/package.json @@ -2,7 +2,7 @@ "name": "@fluid-internal/local-server-stress-tests", "version": "2.22.0", "private": true, - "description": "Tests that can only run against the local server", + "description": "Stress tests that can only run against the local server", "homepage": "https://fluidframework.com", "repository": { "type": "git", diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 3fe231190b8b..8ba1e5771636 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -77,6 +77,9 @@ const generateSubModelMap = ( return modelMap; }; +/** + * here we import the dds models, and do some minor changes to make this easier to nest in the local server stress model. + */ export const ddsModelMap = generateSubModelMap( baseMapModel, baseDirModel, @@ -116,6 +119,11 @@ const covertLocalServerStateToDdsState = async ( random: { ...state.random, handle: () => { + /** + * here we do some funky stuff with handles so we can serialize them like json for output, but not bind them, + * as they may not be attached. look at the reduce code to see how we deserialized these fake handles into real + * handles. + */ const { tag, handle } = state.random.pick(allHandles); const realHandle = toFluidHandleInternal(handle); return { @@ -181,6 +189,11 @@ export const DDSModelOpReducer: AsyncReducer export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { const buildChannelMap = async (client: Client) => { + /** + * here we build a map of all the channels in the container based on their absolute path, + * once we have this we can match channels in different container (clientA and clientB), + * and then reuse the per dds validators to ensure eventual consistency. + */ const channelMap = new Map(); for (const entry of (await client.entryPoint.getContainerObjects()).map((v) => v.type === "stressDataObject" ? v : undefined, diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 5298eda53969..3ca6dac59e71 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -159,12 +159,14 @@ function getSaveInfo( seed: number, ): SaveInfo { return { - saveOnFailure: options.saveFailures - ? { path: getSavePath(options.saveFailures.directory, model, seed) } - : false, - saveOnSuccess: options.saveSuccesses - ? { path: getSavePath(options.saveSuccesses.directory, model, seed) } - : false, + saveOnFailure: + options.saveFailures !== undefined + ? { path: getSavePath(options.saveFailures.directory, model, seed) } + : false, + saveOnSuccess: + options.saveSuccesses !== undefined + ? { path: getSavePath(options.saveSuccesses.directory, model, seed) } + : false, }; } @@ -403,7 +405,7 @@ export interface LocalServerStressOptions { * * Turning on this feature is encouraged for quick minimization. */ - saveFailures: false | { directory: string }; + saveFailures: undefined | { directory: string }; /** * Whether successful runs should be saved to disk and where. @@ -411,7 +413,7 @@ export interface LocalServerStressOptions { * * This feature is useful to audit the scenarios generated by a given fuzz configuration. */ - saveSuccesses: false | { directory: string }; + saveSuccesses: undefined | { directory: string }; /** * Whether or not to skip minimization of fuzz failing test cases. This is useful @@ -443,8 +445,8 @@ const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { parseOperations: (serialized: string) => JSON.parse(serialized) as BaseOperation[], reconnectProbability: 0, rebaseProbability: 0, - saveFailures: false, - saveSuccesses: false, + saveFailures: undefined, + saveSuccesses: undefined, validationStrategy: { type: "random", probability: 0.05 }, }; @@ -693,7 +695,7 @@ function mixinSynchronization< // TODO: Only synchronize listed clients if specified if (isSynchronizeOp(operation)) { const connectedClients = state.clients.filter((client) => { - if (client.container.closed || client.container.disposed) { + if (client.container.closed || client.container.disposed === true) { throw new Error(`Client ${client.tag} is closed`); } return client.container.connectionState !== ConnectionState.Disconnected; @@ -1016,7 +1018,7 @@ async function runTestForSeed( const finalState = await performFuzzActionsAsync( generator, async (state, operation) => { - options.emitter.emit("operation"); + options.emitter.emit("operationStart", operation); operationCount++; return model.reducer(state, operation); }, @@ -1041,9 +1043,12 @@ function runTest( ): void { const itFn = options.only.has(seed) ? it.only : options.skip.has(seed) ? it.skip : it; itFn(`workload: ${model.workloadName} seed: ${seed}`, async function () { - const inCi = !!process.env.TF_BUILD; - const shouldMinimize = - !options.skipMinimization && saveInfo && saveInfo.saveOnFailure !== false && !inCi; + const inCi = process.env.TF_BUILD !== undefined; + const shouldMinimize: boolean = + options.skipMinimization !== true && + saveInfo !== undefined && + saveInfo.saveOnFailure !== false && + !inCi; // 10 seconds per test should be quite a bit more than is necessary, but // a timeout during minimization can cause bad UX because it obfuscates @@ -1058,10 +1063,10 @@ function runTest( // don't write to files in CI await runTestForSeed(model, options, seed, inCi ? undefined : saveInfo); } catch (error) { - if (!shouldMinimize) { + if (!shouldMinimize || saveInfo === undefined) { throw error; } - const savePath: string = (saveInfo.saveOnFailure as SaveDestination).path; + const savePath: string = (saveInfo?.saveOnFailure as SaveDestination).path; let file: Buffer; try { file = readFileSync(savePath); @@ -1180,12 +1185,12 @@ export function createLocalServerStressSuite( const describeFuzz = createFuzzDescribe({ defaultTestCount: options.defaultTestCount }); describeFuzz(model.workloadName, ({ testCount, stressMode }) => { before(() => { - if (options.saveFailures !== false) { + if (options.saveFailures !== undefined) { mkdirSync(getSaveDirectory(options.saveFailures.directory, model), { recursive: true, }); } - if (options.saveSuccesses !== false) { + if (options.saveSuccesses !== undefined) { mkdirSync(getSaveDirectory(options.saveSuccesses.directory, model), { recursive: true, }); diff --git a/packages/test/local-server-stress-tests/src/minification.ts b/packages/test/local-server-stress-tests/src/minification.ts index f0e2a5c6ac6f..f61840db247b 100644 --- a/packages/test/local-server-stress-tests/src/minification.ts +++ b/packages/test/local-server-stress-tests/src/minification.ts @@ -210,7 +210,7 @@ export class FuzzTestMinimizer { return false; } catch (error: unknown) { if ( - !error || + error === undefined || !(error instanceof Error) || error instanceof ReducerPreconditionError || error.stack === undefined diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index babbf3332739..c9a129bf0411 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -21,7 +21,12 @@ import type { FluidObject, IFluidLoadable, } from "@fluidframework/core-interfaces"; -import { assert, Lazy, LazyPromise } from "@fluidframework/core-utils/internal"; +import { + assert, + Lazy, + LazyPromise, + unreachableCase, +} from "@fluidframework/core-utils/internal"; import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import { ISharedMap, SharedMap } from "@fluidframework/map/internal"; import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; @@ -118,14 +123,13 @@ export class StressDataObject extends DataObject { return this.runtime.attachState === AttachState.Attached; } - public uploadBlob(tag: `blob-${number}`, contents: string) { - void this.runtime.uploadBlob(stringToBuffer(contents, "utf-8")).then((handle) => - this.defaultStressObject.registerLocallyCreatedObject({ - type: "newBlob", - handle, - tag, - }), - ); + public async uploadBlob(tag: `blob-${number}`, contents: string) { + const handle = await this.runtime.uploadBlob(stringToBuffer(contents, "utf-8")); + this.defaultStressObject.registerLocallyCreatedObject({ + type: "newBlob", + handle, + tag, + }); } public createChannel(tag: `channel-${number}`, type: string) { @@ -133,24 +137,21 @@ export class StressDataObject extends DataObject { this.channelNameMap.set(tag, type); } - public createDataStore(tag: `datastore-${number}`, asChild: boolean) { - void this.context.containerRuntime - .createDataStore( - asChild - ? [...this.context.packagePath, StressDataObject.factory.value.type] - : StressDataObject.factory.value.type, - ) - .then(async (dataStore) => { - const maybe: FluidObject | undefined = - await dataStore.entryPoint.get(); - assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); - this.defaultStressObject.registerLocallyCreatedObject({ - type: "stressDataObject", - handle: dataStore.entryPoint, - tag, - stressDataObject: maybe.StressDataObject, - }); - }); + public async createDataStore(tag: `datastore-${number}`, asChild: boolean) { + const dataStore = await this.context.containerRuntime.createDataStore( + asChild + ? [...this.context.packagePath, StressDataObject.factory.value.type] + : StressDataObject.factory.value.type, + ); + + const maybe: FluidObject | undefined = await dataStore.entryPoint.get(); + assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); + this.defaultStressObject.registerLocallyCreatedObject({ + type: "stressDataObject", + handle: dataStore.entryPoint, + tag, + stressDataObject: maybe.StressDataObject, + }); } } export type ContainerObjects = @@ -176,10 +177,13 @@ export class DefaultStressDataObject extends StressDataObject { */ private readonly _locallyCreatedObjects: ContainerObjects[] = []; public async getContainerObjects(): Promise[]> { - const globalObjects: Readonly[] = [...this._locallyCreatedObjects]; + const containerObjects: Readonly[] = [...this._locallyCreatedObjects]; const containerRuntime = // eslint-disable-next-line import/no-deprecated this.context.containerRuntime as IContainerRuntimeWithResolveHandle_Deprecated; - for (const url of this.containerObjectMap.keys()) { + for (const [url, entry] of this.containerObjectMap as any as [ + string, + ContainerObjects, + ][]) { // the container objects map will see things before they are attached, // so they may not be available to remote clients yet. // Additionally, there is no way to observe when an @@ -195,10 +199,10 @@ export class DefaultStressDataObject extends StressDataObject { const maybe: FluidObject | undefined = resp.value; const handle = maybe?.IFluidLoadable?.handle; if (handle !== undefined) { - const entry = this.containerObjectMap.get(url); - switch (entry?.type) { + const type = entry?.type; + switch (type) { case "newBlob": - globalObjects.push({ + containerObjects.push({ ...entry, handle, }); @@ -206,7 +210,7 @@ export class DefaultStressDataObject extends StressDataObject { case "stressDataObject": assert(maybe?.StressDataObject !== undefined, "must be stressDataObject"); - globalObjects.push({ + containerObjects.push({ type: "stressDataObject", tag: entry.tag, handle, @@ -214,11 +218,12 @@ export class DefaultStressDataObject extends StressDataObject { }); break; default: + unreachableCase(type, `${type}`); } } } } - return globalObjects; + return containerObjects; } protected override async getDefaultStressDataObject(): Promise { @@ -251,7 +256,7 @@ export class DefaultStressDataObject extends StressDataObject { } public registerLocallyCreatedObject(obj: ContainerObjects) { - if (obj.handle) { + if (obj.handle !== undefined) { const handle = toFluidHandleInternal(obj.handle); if (this.containerObjectMap.get(handle.absolutePath) === undefined) { this.containerObjectMap.set(handle.absolutePath, { tag: obj.tag, type: obj.type }); diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index f253b4ccd019..71ff58c2d607 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -31,15 +31,13 @@ import { _dirname } from "./dirname.cjs"; type StressOperations = StressDataObjectOperations | DDSModelOp; const reducer = combineReducersAsync({ - createDataStore: async (state, op) => { - state.datastore.createDataStore(op.tag, op.asChild); - }, + createDataStore: async (state, op) => state.datastore.createDataStore(op.tag, op.asChild), createChannel: async (state, op) => { state.datastore.createChannel(op.tag, op.channelType); }, - uploadBlob: async (state, op) => { - state.datastore.uploadBlob(op.tag, state.random.string(state.random.integer(1, 16))); - }, + uploadBlob: async (state, op) => + // this will hang if we are offline due to disconnect, so we don't wait for blob upload + void state.datastore.uploadBlob(op.tag, state.random.string(state.random.integer(1, 16))), DDSModelOp: DDSModelOpReducer, }); From 3c94a512b2bd17668e599ab7d952efcd12ab9c1f Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 19 Feb 2025 13:54:00 -0800 Subject: [PATCH 49/54] clean up initialization --- .../src/ddsModels.ts | 167 +----------------- .../src/ddsOperations.ts | 157 ++++++++++++++++ .../src/localServerStressHarness.ts | 12 +- .../src/stressDataObject.ts | 58 +++--- .../src/test/localServerStress.spec.ts | 4 +- .../local-server-stress-tests/src/utils.ts | 15 ++ 6 files changed, 202 insertions(+), 211 deletions(-) create mode 100644 packages/test/local-server-stress-tests/src/ddsOperations.ts create mode 100644 packages/test/local-server-stress-tests/src/utils.ts diff --git a/packages/test/local-server-stress-tests/src/ddsModels.ts b/packages/test/local-server-stress-tests/src/ddsModels.ts index 8ba1e5771636..7b2df9db91a1 100644 --- a/packages/test/local-server-stress-tests/src/ddsModels.ts +++ b/packages/test/local-server-stress-tests/src/ddsModels.ts @@ -3,38 +3,18 @@ * Licensed under the MIT License. */ -import { - done, - type AsyncGenerator, - type AsyncReducer, -} from "@fluid-private/stochastic-test-utils"; -import { - DDSFuzzModel, - DDSFuzzTestState, - Client as DDSClient, -} from "@fluid-private/test-dds-utils"; -import { fluidHandleSymbol } from "@fluidframework/core-interfaces"; -import { assert, isObject } from "@fluidframework/core-utils/internal"; -import type { - IChannel, - IChannelFactory, -} from "@fluidframework/datastore-definitions/internal"; +import { done, type AsyncGenerator } from "@fluid-private/stochastic-test-utils"; +import { DDSFuzzModel, DDSFuzzTestState } from "@fluid-private/test-dds-utils"; +import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; // eslint-disable-next-line import/no-internal-modules import { baseMapModel, baseDirModel } from "@fluidframework/map/internal/test"; -import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import { baseSharedStringModel, baseIntervalModel, // eslint-disable-next-line import/no-internal-modules } from "@fluidframework/sequence/internal/test"; -import { - LocalServerStressState, - makeUnreachableCodePathProxy, - Client, -} from "./localServerStressHarness"; - -export function repeatFactoryAsync( +function repeatFactoryAsync( factory: () => AsyncGenerator, ): AsyncGenerator { let generator = factory(); @@ -86,142 +66,3 @@ export const ddsModelMap = generateSubModelMap( baseSharedStringModel, baseIntervalModel, ); - -export interface DDSModelOp { - type: "DDSModelOp"; - op: unknown; -} - -const createDDSClient = (channel: IChannel): DDSClient => { - return { - channel, - containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), - dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), - }; -}; - -const covertLocalServerStateToDdsState = async ( - state: LocalServerStressState, -): Promise> => { - const channels = await state.datastore.getChannels(); - const allHandles = [ - ...channels.map((c) => ({ tag: c.id, handle: c.handle })), - ...(await state.client.entryPoint.getContainerObjects()).filter( - (v) => v.handle !== undefined, - ), - ]; - return { - clients: makeUnreachableCodePathProxy("clients"), - client: createDDSClient(state.channel), - containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), - isDetached: state.isDetached, - summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), - random: { - ...state.random, - handle: () => { - /** - * here we do some funky stuff with handles so we can serialize them like json for output, but not bind them, - * as they may not be attached. look at the reduce code to see how we deserialized these fake handles into real - * handles. - */ - const { tag, handle } = state.random.pick(allHandles); - const realHandle = toFluidHandleInternal(handle); - return { - tag, - absolutePath: realHandle.absolutePath, - get [fluidHandleSymbol]() { - return realHandle[fluidHandleSymbol]; - }, - async get() { - return realHandle.get(); - }, - get isAttached() { - return realHandle.isAttached; - }, - }; - }, - }, - }; -}; - -export const DDSModelOpGenerator: AsyncGenerator = async ( - state, -) => { - const channel = state.channel; - const model = ddsModelMap.get(channel.attributes.type); - assert(model !== undefined, "must have model"); - - const op = await model.generator(await covertLocalServerStateToDdsState(state)); - - return { - type: "DDSModelOp", - op, - } satisfies DDSModelOp; -}; - -export const DDSModelOpReducer: AsyncReducer = async ( - state, - op, -) => { - const baseModel = ddsModelMap.get(state.channel.attributes.type); - assert(baseModel !== undefined, "must have base model"); - const channels = await state.datastore.getChannels(); - const globalObjects = await state.client.entryPoint.getContainerObjects(); - const allHandles = [ - ...channels.map((c) => ({ tag: c.id, handle: c.handle })), - ...globalObjects.filter((v) => v.handle !== undefined), - ]; - - // we always serialize and then deserialize withe a handle look - // up, as this ensure we all do the same thing, regardless of if - // we are replaying from a file with serialized generated operations, or - // running live with in-memory generated operations. - const subOp = JSON.parse(JSON.stringify(op.op), (key, value: unknown) => { - if (isObject(value) && "absolutePath" in value && "tag" in value) { - const entry = allHandles.find((h) => h.tag === value.tag); - assert(entry !== undefined, "entry must exist"); - return entry.handle; - } - return value; - }); - await baseModel.reducer(await covertLocalServerStateToDdsState(state), subOp); -}; - -export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { - const buildChannelMap = async (client: Client) => { - /** - * here we build a map of all the channels in the container based on their absolute path, - * once we have this we can match channels in different container (clientA and clientB), - * and then reuse the per dds validators to ensure eventual consistency. - */ - const channelMap = new Map(); - for (const entry of (await client.entryPoint.getContainerObjects()).map((v) => - v.type === "stressDataObject" ? v : undefined, - )) { - if (entry !== undefined) { - const stressDataObject = entry?.stressDataObject; - if (stressDataObject?.attached === true) { - const channels = await stressDataObject.getChannels(); - for (const channel of channels) { - if (channel.isAttached()) { - channelMap.set(`${entry.tag}/${channel.id}`, channel); - } - } - } - } - } - return channelMap; - }; - const aMap = await buildChannelMap(clientA); - const bMap = await buildChannelMap(clientB); - assert(aMap.size === bMap.size, "channel maps should be the same size"); - for (const key of aMap.keys()) { - const aChannel = aMap.get(key); - const bChannel = bMap.get(key); - assert(aChannel !== undefined, "channel must exist"); - assert(aChannel.attributes.type === bChannel?.attributes.type, "channel types must match"); - const model = ddsModelMap.get(aChannel.attributes.type); - assert(model !== undefined, "model must exist"); - await model.validateConsistency(createDDSClient(aChannel), createDDSClient(bChannel)); - } -}; diff --git a/packages/test/local-server-stress-tests/src/ddsOperations.ts b/packages/test/local-server-stress-tests/src/ddsOperations.ts new file mode 100644 index 000000000000..e51a0b2d5f73 --- /dev/null +++ b/packages/test/local-server-stress-tests/src/ddsOperations.ts @@ -0,0 +1,157 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { type AsyncGenerator, type AsyncReducer } from "@fluid-private/stochastic-test-utils"; +import { DDSFuzzTestState, Client as DDSClient } from "@fluid-private/test-dds-utils"; +import { fluidHandleSymbol } from "@fluidframework/core-interfaces"; +import { assert, isObject } from "@fluidframework/core-utils/internal"; +import type { + IChannel, + IChannelFactory, +} from "@fluidframework/datastore-definitions/internal"; +import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; + +import { ddsModelMap } from "./ddsModels.js"; +import { LocalServerStressState, Client } from "./localServerStressHarness.js"; +import { makeUnreachableCodePathProxy } from "./utils.js"; + +export interface DDSModelOp { + type: "DDSModelOp"; + op: unknown; +} + +const createDDSClient = (channel: IChannel): DDSClient => { + return { + channel, + containerRuntime: makeUnreachableCodePathProxy("containerRuntime"), + dataStoreRuntime: makeUnreachableCodePathProxy("dataStoreRuntime"), + }; +}; + +const covertLocalServerStateToDdsState = async ( + state: LocalServerStressState, +): Promise> => { + const channels = await state.datastore.getChannels(); + const allHandles = [ + ...channels.map((c) => ({ tag: c.id, handle: c.handle })), + ...(await state.client.entryPoint.getContainerObjects()).filter( + (v) => v.handle !== undefined, + ), + ]; + return { + clients: makeUnreachableCodePathProxy("clients"), + client: createDDSClient(state.channel), + containerRuntimeFactory: makeUnreachableCodePathProxy("containerRuntimeFactory"), + isDetached: state.isDetached, + summarizerClient: makeUnreachableCodePathProxy("containerRuntimeFactory"), + random: { + ...state.random, + handle: () => { + /** + * here we do some funky stuff with handles so we can serialize them like json for output, but not bind them, + * as they may not be attached. look at the reduce code to see how we deserialized these fake handles into real + * handles. + */ + const { tag, handle } = state.random.pick(allHandles); + const realHandle = toFluidHandleInternal(handle); + return { + tag, + absolutePath: realHandle.absolutePath, + get [fluidHandleSymbol]() { + return realHandle[fluidHandleSymbol]; + }, + async get() { + return realHandle.get(); + }, + get isAttached() { + return realHandle.isAttached; + }, + }; + }, + }, + }; +}; + +export const DDSModelOpGenerator: AsyncGenerator = async ( + state, +) => { + const channel = state.channel; + const model = ddsModelMap.get(channel.attributes.type); + assert(model !== undefined, "must have model"); + + const op = await model.generator(await covertLocalServerStateToDdsState(state)); + + return { + type: "DDSModelOp", + op, + } satisfies DDSModelOp; +}; + +export const DDSModelOpReducer: AsyncReducer = async ( + state, + op, +) => { + const baseModel = ddsModelMap.get(state.channel.attributes.type); + assert(baseModel !== undefined, "must have base model"); + const channels = await state.datastore.getChannels(); + const globalObjects = await state.client.entryPoint.getContainerObjects(); + const allHandles = [ + ...channels.map((c) => ({ tag: c.id, handle: c.handle })), + ...globalObjects.filter((v) => v.handle !== undefined), + ]; + + // we always serialize and then deserialize withe a handle look + // up, as this ensure we all do the same thing, regardless of if + // we are replaying from a file with serialized generated operations, or + // running live with in-memory generated operations. + const subOp = JSON.parse(JSON.stringify(op.op), (key, value: unknown) => { + if (isObject(value) && "absolutePath" in value && "tag" in value) { + const entry = allHandles.find((h) => h.tag === value.tag); + assert(entry !== undefined, "entry must exist"); + return entry.handle; + } + return value; + }); + await baseModel.reducer(await covertLocalServerStateToDdsState(state), subOp); +}; + +export const validateConsistencyOfAllDDS = async (clientA: Client, clientB: Client) => { + const buildChannelMap = async (client: Client) => { + /** + * here we build a map of all the channels in the container based on their absolute path, + * once we have this we can match channels in different container (clientA and clientB), + * and then reuse the per dds validators to ensure eventual consistency. + */ + const channelMap = new Map(); + for (const entry of (await client.entryPoint.getContainerObjects()).map((v) => + v.type === "stressDataObject" ? v : undefined, + )) { + if (entry !== undefined) { + const stressDataObject = entry?.stressDataObject; + if (stressDataObject?.attached === true) { + const channels = await stressDataObject.getChannels(); + for (const channel of channels) { + if (channel.isAttached()) { + channelMap.set(`${entry.tag}/${channel.id}`, channel); + } + } + } + } + } + return channelMap; + }; + const aMap = await buildChannelMap(clientA); + const bMap = await buildChannelMap(clientB); + assert(aMap.size === bMap.size, "channel maps should be the same size"); + for (const key of aMap.keys()) { + const aChannel = aMap.get(key); + const bChannel = bMap.get(key); + assert(aChannel !== undefined, "channel must exist"); + assert(aChannel.attributes.type === bChannel?.attributes.type, "channel types must match"); + const model = ddsModelMap.get(aChannel.attributes.type); + assert(model !== undefined, "model must exist"); + await model.validateConsistency(createDDSClient(aChannel), createDDSClient(bChannel)); + } +}; diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 3ca6dac59e71..536c8a394f02 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -61,6 +61,7 @@ import { StressDataObject, type DefaultStressDataObject, } from "./stressDataObject.js"; +import { makeUnreachableCodePathProxy } from "./utils.js"; const isOperationType = ( type: O["type"], @@ -898,17 +899,6 @@ async function runInStateWithClient(name: string): T { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return new Proxy({} as T, { - get: (): never => { - throw new Error( - `Unexpected read of '${name}:' this indicates a bug in the DDS eventual consistency harness.`, - ); - }, - }); -} - async function createDetachedClient( localDeltaConnectionServer: ILocalDeltaConnectionServer, codeLoader: ICodeDetailsLoader, diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts index c9a129bf0411..8e0a18ab2ff5 100644 --- a/packages/test/local-server-stress-tests/src/stressDataObject.ts +++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts @@ -21,18 +21,13 @@ import type { FluidObject, IFluidLoadable, } from "@fluidframework/core-interfaces"; -import { - assert, - Lazy, - LazyPromise, - unreachableCase, -} from "@fluidframework/core-utils/internal"; +import { assert, LazyPromise, unreachableCase } from "@fluidframework/core-utils/internal"; import type { IChannel } from "@fluidframework/datastore-definitions/internal"; import { ISharedMap, SharedMap } from "@fluidframework/map/internal"; import { toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; -import { ddsModelMap } from "./ddsModels"; -import { makeUnreachableCodePathProxy } from "./localServerStressHarness"; +import { ddsModelMap } from "./ddsModels.js"; +import { makeUnreachableCodePathProxy } from "./utils.js"; export interface UploadBlob { type: "uploadBlob"; @@ -53,16 +48,13 @@ export interface CreateChannel { export type StressDataObjectOperations = UploadBlob | CreateDataStore | CreateChannel; export class StressDataObject extends DataObject { - public static readonly factory = new Lazy(() => { - const factory = new DataObjectFactory( - "StressDataObject", - StressDataObject, - [...ddsModelMap.values()].map((v) => v.factory), - {}, - [["StressDataObject", new LazyPromise(() => factory)]], - ); - return factory; - }); + public static readonly factory: DataObjectFactory = new DataObjectFactory( + "StressDataObject", + StressDataObject, + [...ddsModelMap.values()].map((v) => v.factory), + {}, + [["StressDataObject", new LazyPromise(async () => StressDataObject.factory)]], + ); get StressDataObject() { return this; @@ -140,8 +132,8 @@ export class StressDataObject extends DataObject { public async createDataStore(tag: `datastore-${number}`, asChild: boolean) { const dataStore = await this.context.containerRuntime.createDataStore( asChild - ? [...this.context.packagePath, StressDataObject.factory.value.type] - : StressDataObject.factory.value.type, + ? [...this.context.packagePath, StressDataObject.factory.type] + : StressDataObject.factory.type, ); const maybe: FluidObject | undefined = await dataStore.entryPoint.get(); @@ -272,7 +264,7 @@ export const createRuntimeFactory = (): IRuntimeFactory => { DefaultStressDataObject, [...ddsModelMap.values()].map((v) => v.factory), {}, - [[StressDataObject.factory.value.type, StressDataObject.factory.value]], + [[StressDataObject.factory.type, StressDataObject.factory]], ); const runtimeOptions: IContainerRuntimeOptionsInternal = { @@ -289,7 +281,7 @@ export const createRuntimeFactory = (): IRuntimeFactory => { return this; }, instantiateRuntime: async (context, existing) => { - return loadContainerRuntime({ + const runtime = await loadContainerRuntime({ context, existing, runtimeOptions, @@ -298,28 +290,24 @@ export const createRuntimeFactory = (): IRuntimeFactory => { defaultStressDataObjectFactory.type, Promise.resolve(defaultStressDataObjectFactory), ], - [ - StressDataObject.factory.value.type, - Promise.resolve(StressDataObject.factory.value), - ], + [StressDataObject.factory.type, Promise.resolve(StressDataObject.factory)], ], provideEntryPoint: async (rt) => { - const maybeDefault = await rt.getAliasedDataStoreEntryPoint( - DefaultStressDataObject.alias, - ); - if (maybeDefault === undefined) { - const ds = await rt.createDataStore(defaultStressDataObjectFactory.type); - await ds.trySetAlias(DefaultStressDataObject.alias); - } const aliasedDefault = await rt.getAliasedDataStoreEntryPoint( DefaultStressDataObject.alias, ); assert(aliasedDefault !== undefined, "default must exist"); - const maybe: FluidObject | undefined = await aliasedDefault.get(); - return maybe; + return aliasedDefault.get(); }, }); + + if (!existing) { + const ds = await runtime.createDataStore(defaultStressDataObjectFactory.type); + await ds.trySetAlias(DefaultStressDataObject.alias); + } + + return runtime; }, }; }; diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index 71ff58c2d607..dbd521d36b89 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -12,13 +12,13 @@ import { takeAsync, } from "@fluid-private/stochastic-test-utils"; +import { ddsModelMap } from "../ddsModels.js"; import { - ddsModelMap, DDSModelOpGenerator, DDSModelOpReducer, validateConsistencyOfAllDDS, type DDSModelOp, -} from "../ddsModels.js"; +} from "../ddsOperations"; import { createLocalServerStressSuite, LocalServerStressModel, diff --git a/packages/test/local-server-stress-tests/src/utils.ts b/packages/test/local-server-stress-tests/src/utils.ts new file mode 100644 index 000000000000..910dc88c2fea --- /dev/null +++ b/packages/test/local-server-stress-tests/src/utils.ts @@ -0,0 +1,15 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export function makeUnreachableCodePathProxy(name: string): T { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return new Proxy({} as T, { + get: (): never => { + throw new Error( + `Unexpected read of '${name}:' this indicates a bug in the DDS eventual consistency harness.`, + ); + }, + }); +} From bd3aebba6632973b710467c177f1ebdc7bc04c3e Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Wed, 19 Feb 2025 16:45:04 -0800 Subject: [PATCH 50/54] move reusable parts to stochastic-test-utils --- .../dds/test-dds-utils/src/ddsFuzzHarness.ts | 96 ++----- packages/dds/test-dds-utils/src/index.ts | 2 - .../dds/test-dds-utils/src/minification.ts | 250 ------------------ .../src/test/ddsFuzzHarness.spec.ts | 51 +--- .../test-dds-utils/src/test/sharedNothing.ts | 3 +- .../src/test/fuzz/summarizerFuzzSuite.ts | 43 +-- .../src/localServerStressHarness.ts | 109 ++------ .../src/combineReducers.ts | 29 +- .../stochastic-test-utils/src/describeFuzz.ts | 30 +++ .../test/stochastic-test-utils/src/index.ts | 15 +- .../src/minification.ts | 48 ++-- .../test/stochastic-test-utils/src/results.ts | 55 ++++ .../src/test/generateTestSeeds.spec.ts | 53 ++++ 13 files changed, 243 insertions(+), 541 deletions(-) delete mode 100644 packages/dds/test-dds-utils/src/minification.ts rename packages/test/{local-server-stress-tests => stochastic-test-utils}/src/minification.ts (84%) create mode 100644 packages/test/stochastic-test-utils/src/results.ts create mode 100644 packages/test/stochastic-test-utils/src/test/generateTestSeeds.spec.ts diff --git a/packages/dds/test-dds-utils/src/ddsFuzzHarness.ts b/packages/dds/test-dds-utils/src/ddsFuzzHarness.ts index 4ac3557f0dff..5f7bc6ac53e3 100644 --- a/packages/dds/test-dds-utils/src/ddsFuzzHarness.ts +++ b/packages/dds/test-dds-utils/src/ddsFuzzHarness.ts @@ -5,27 +5,32 @@ import { strict as assert } from "node:assert"; import { mkdirSync, readFileSync } from "node:fs"; -import path from "node:path"; import { TypedEventEmitter } from "@fluid-internal/client-utils"; import type { AsyncGenerator, AsyncReducer, BaseFuzzTestState, + BaseOperation, IRandom, + MinimizationTransform, SaveDestination, SaveInfo, } from "@fluid-private/stochastic-test-utils"; import { ExitBehavior, - StressMode, + FuzzTestMinimizer, asyncGeneratorFromArray, chainAsync, createFuzzDescribe, createWeightedAsyncGenerator, defaultOptions, done, + generateTestSeeds, + getSaveDirectory, + getSaveInfo, interleaveAsync, + isOperationType, makeRandom, performFuzzActionsAsync, saveOpsToFile, @@ -59,13 +64,6 @@ import { hasStashData, } from "./clientLoading.js"; import { DDSFuzzHandle } from "./ddsFuzzHandle.js"; -import type { MinimizationTransform } from "./minification.js"; -import { FuzzTestMinimizer } from "./minification.js"; - -const isOperationType = ( - type: O["type"], - op: BaseOperation, -): op is O => op.type === type; /** * @internal @@ -104,13 +102,6 @@ export interface ClientSpec { clientId: string; } -/** - * @internal - */ -export interface BaseOperation { - type: number | string; -} - /** * @internal */ @@ -182,37 +173,6 @@ export interface Synchronize { clients?: string[]; } -/** - * @internal - */ -interface HasWorkloadName { - workloadName: string; -} - -function getSaveDirectory(directory: string, model: HasWorkloadName): string { - const workloadFriendly = model.workloadName.replace(/[\s_]+/g, "-").toLowerCase(); - return path.join(directory, workloadFriendly); -} - -function getSavePath(directory: string, model: HasWorkloadName, seed: number): string { - return path.join(getSaveDirectory(directory, model), `${seed}.json`); -} - -function getSaveInfo( - model: HasWorkloadName, - options: DDSFuzzSuiteOptions, - seed: number, -): SaveInfo { - return { - saveOnFailure: options.saveFailures - ? { path: getSavePath(options.saveFailures.directory, model, seed) } - : false, - saveOnSuccess: options.saveSuccesses - ? { path: getSavePath(options.saveSuccesses.directory, model, seed) } - : false, - }; -} - /** * Represents a generic fuzz model for testing eventual consistency of a DDS. * @@ -297,7 +257,7 @@ export interface DDSFuzzModel< /** * An array of transforms used during fuzz test minimization to reduce test - * cases. See {@link MinimizationTransform} for additional context. + * cases. See {@link @fluid-private/stochastic-test-utils#MinimizationTransform} for additional context. * * If no transforms are supplied, minimization will still occur, but the * contents of the operations will remain unchanged. @@ -529,7 +489,7 @@ export interface DDSFuzzSuiteOptions { * useful if the model being tested defines {@link DDSFuzzModel.minimizationTransforms}. * * It can also add a couple seconds of overhead per failing - * test case. See {@link MinimizationTransform} for additional context. + * test case. See {@link @fluid-private/stochastic-test-utils#MinimizationTransform} for additional context. */ skipMinimization?: boolean; @@ -1526,7 +1486,13 @@ function runTest replayTest(model, seed, generator, saveInfo, options), + 3, + ); const minimized = await minimizer.minimize(); await saveOpsToFile(savePath, minimized); @@ -1566,7 +1532,7 @@ export async function replayTest< >( ddsModel: DDSFuzzModel, seed: number, - operations: TOperation[], + generator: AsyncGenerator, saveInfo?: SaveInfo, providedOptions?: Partial, ): Promise { @@ -1582,38 +1548,12 @@ export async function replayTest< const model = { ..._model, // We lose some type safety here because the options interface isn't generic - generatorFactory: (): AsyncGenerator => - asyncGeneratorFromArray(operations), + generatorFactory: (): AsyncGenerator => generator, }; await runTestForSeed(model, options, seed, saveInfo); } -export function generateTestSeeds(testCount: number, stressMode: StressMode): number[] { - switch (stressMode) { - case StressMode.Short: - case StressMode.Normal: { - // Deterministic, fixed seeds - return Array.from({ length: testCount }, (_, i) => i); - } - - case StressMode.Long: { - // Non-deterministic, random seeds - const random = makeRandom(); - const longModeFactor = 2; - const initialSeed = random.integer( - 0, - Number.MAX_SAFE_INTEGER - longModeFactor * testCount, - ); - return Array.from({ length: testCount * longModeFactor }, (_, i) => initialSeed + i); - } - - default: { - throw new Error(`Unsupported stress mode: ${stressMode}`); - } - } -} - /** * Creates a suite of eventual consistency tests for a particular DDS model. * @internal diff --git a/packages/dds/test-dds-utils/src/index.ts b/packages/dds/test-dds-utils/src/index.ts index 837d2f580827..9e237c3bce28 100644 --- a/packages/dds/test-dds-utils/src/index.ts +++ b/packages/dds/test-dds-utils/src/index.ts @@ -7,7 +7,6 @@ export type { IGCTestProvider } from "./gcTestRunner.js"; export { runGCTests } from "./gcTestRunner.js"; export type { AddClient, - BaseOperation, ChangeConnectionState, ClientSpec, DDSFuzzModel, @@ -24,5 +23,4 @@ export { } from "./ddsFuzzHarness.js"; export type { ISnapshotSuite } from "./ddsSnapshotHarness.js"; export { createSnapshotSuite } from "./ddsSnapshotHarness.js"; -export type { MinimizationTransform } from "./minification.js"; export type { Client, FuzzSerializedIdCompressor } from "./clientLoading.js"; diff --git a/packages/dds/test-dds-utils/src/minification.ts b/packages/dds/test-dds-utils/src/minification.ts deleted file mode 100644 index 8cf72262c52c..000000000000 --- a/packages/dds/test-dds-utils/src/minification.ts +++ /dev/null @@ -1,250 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import { TypedEventEmitter } from "@fluid-internal/client-utils"; -import type { SaveInfo } from "@fluid-private/stochastic-test-utils"; -import { makeRandom } from "@fluid-private/stochastic-test-utils"; -import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; - -import type { - BaseOperation, - DDSFuzzHarnessEvents, - DDSFuzzModel, - DDSFuzzSuiteOptions, -} from "./ddsFuzzHarness.js"; -import { ReducerPreconditionError, replayTest } from "./ddsFuzzHarness.js"; - -/** - * A function which takes in an operation and modifies it by reference to be more - * minimal. - * - * This function should be a small step forward and should avoid expensive - * computations, as it will be run potentially thousands of times. - * - * A good example of a minimization transform is: - * - * ```ts - * (op) => { - * // this transform only applies to text insertion ops - * if (op.type !== "addText") { - * return; - * } - * - * // shift the insertion index to the left by one. this makes the index - * // a smaller number and may allow other ops to be shifted to the left - * // as well - * if (op.index > 0) { - * op.index -= 1; - * } - * } - * ``` - * - * @internal - */ -export type MinimizationTransform = (op: TOperation) => void; - -export class FuzzTestMinimizer< - TChannelFactory extends IChannelFactory, - TOperation extends BaseOperation, -> { - private initialError?: { message: string; op: BaseOperation }; - private readonly transforms: MinimizationTransform[]; - private readonly random = makeRandom(); - - constructor( - readonly ddsModel: DDSFuzzModel, - readonly providedOptions: Partial, - readonly operations: TOperation[], - readonly seed: number, - readonly saveInfo: SaveInfo, - readonly numIterations: number = 1000, - ) { - this.transforms = ddsModel.minimizationTransforms ?? []; - } - - async minimize(): Promise { - const firstError = await this.assertFails(); - - if (!firstError) { - throw new Error( - "Attempted to minimize fuzz test, but the original case didn't fail. " + - "This can happen if the original test failed at operation generation time rather than as part of a reducer. " + - "Use the `skipMinimization` option to skip minimization in this case.", - ); - } - - await this.tryDeleteEachOp(); - - if (this.transforms.length === 0) { - return this.operations; - } - - for (let i = 0; i < this.numIterations; i += 1) { - await this.applyTransforms(); - // some minimizations can only occur if two or more ops are modified - // at the same time - for (let j = 0; j < 50; j++) { - await this.applyNRandomTransforms(2); - await this.applyNRandomTransforms(3); - } - } - - await this.tryDeleteEachOp(); - - return this.operations; - } - - private async tryDeleteEachOp(): Promise { - let idx = this.operations.length - 1; - - while (idx > 0) { - const deletedOp = this.operations.splice(idx, 1)[0]; - - // don't remove attach ops, as it creates invalid scenarios - if (deletedOp.type === "attach" || !(await this.assertFails())) { - this.operations.splice(idx, 0, deletedOp); - } - - idx -= 1; - } - } - - /** - * Apply all transforms in a random order - */ - private async applyTransforms(): Promise { - const transforms = [...this.transforms]; - this.random.shuffle(transforms); - - for (const transform of transforms) { - await this.applyTransform(transform); - } - } - - private async applyNRandomTransforms(n: number): Promise { - if (n > this.operations.length) { - return; - } - - // select `n` random transforms. duplicates are allowed. - const transforms = Array.from({ length: n }) - .fill(undefined) - .map(() => this.random.pick(this.transforms)); - - // select `n` random operations without duplicates - let operationIdxs = [...Array.from({ length: this.operations.length }).keys()]; - this.random.shuffle(operationIdxs); - operationIdxs = operationIdxs.slice(0, n); - - if (transforms.length !== operationIdxs.length) { - throw new Error( - `mismatch in number of operations and transforms: ${transforms.length} vs ${operationIdxs.length}`, - ); - } - - const originalOperations: [string, number][] = []; - - for (let i = 0; i < transforms.length; i++) { - const transform = transforms[i]; - const op = this.operations[operationIdxs[i]]; - - originalOperations.push([JSON.stringify(op), operationIdxs[i]]); - - transform(op); - } - - if (!(await this.assertFails())) { - for (const [op, idx] of originalOperations) { - this.operations[idx] = JSON.parse(op) as TOperation; - } - } - } - - /** - * Apply a given transform on each op until it can no longer make progress - */ - private async applyTransform(transform: MinimizationTransform): Promise { - for (let opIdx = this.operations.length - 1; opIdx >= 0; opIdx--) { - // apply this transform at most 10 times on the current op - for (let i = 0; i < 10; i++) { - const op = this.operations[opIdx]; - - // deep clone the op as transforms modify by reference - const originalOp = JSON.stringify(op); - - transform(op); - - if (JSON.stringify(op) === originalOp) { - break; - } - - if (!(await this.assertFails())) { - this.operations[opIdx] = JSON.parse(originalOp) as TOperation; - break; - } - } - } - } - - /** - * Returns whether or not the test still fails with the same error message. - * - * We use the simple heuristic of verifying the error message is the same - * to avoid dealing with transforms that would result in invalid ops - */ - private async assertFails(): Promise { - const emitter = (this.providedOptions.emitter ??= - new TypedEventEmitter()); - - let lastOp: BaseOperation = { type: "___none___" }; - const lastOpTracker = (op: BaseOperation): void => { - lastOp = op; - }; - emitter.on("operationStart", lastOpTracker); - try { - await replayTest( - this.ddsModel, - this.seed, - this.operations, - undefined, - this.providedOptions, - ); - return false; - } catch (error: unknown) { - if ( - !error || - !(error instanceof Error) || - error instanceof ReducerPreconditionError || - error.stack === undefined - ) { - return false; - } - - const stackLines = error.stack.split("\n").map((s) => s.trim()); - - const stackTop = stackLines.findIndex((s) => s.startsWith("at")); - - const message = stackLines[stackTop].startsWith("at assert ") - ? // Reproduce based on the final two lines+col of the error if it is an assert error - // This ensures the same assert is triggered by the minified test - stackLines - .slice(stackTop, stackTop + 2) - .join("\n") - : // Otherwise the final line is sufficient - stackLines[stackTop]; - - if (this.initialError === undefined) { - this.initialError = { message, op: lastOp }; - return true; - } - - return ( - message === this.initialError.message && this.initialError.op.type === lastOp.type - ); - } finally { - emitter.off("operation", lastOpTracker); - } - } -} diff --git a/packages/dds/test-dds-utils/src/test/ddsFuzzHarness.spec.ts b/packages/dds/test-dds-utils/src/test/ddsFuzzHarness.spec.ts index b84ff815a075..c816cdd7fd5d 100644 --- a/packages/dds/test-dds-utils/src/test/ddsFuzzHarness.spec.ts +++ b/packages/dds/test-dds-utils/src/test/ddsFuzzHarness.spec.ts @@ -8,8 +8,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { TypedEventEmitter } from "@fluid-internal/client-utils"; -import type { AsyncGenerator } from "@fluid-private/stochastic-test-utils"; -import { chainAsync, done, StressMode, takeAsync } from "@fluid-private/stochastic-test-utils"; +import type { AsyncGenerator, BaseOperation } from "@fluid-private/stochastic-test-utils"; +import { chainAsync, done, takeAsync } from "@fluid-private/stochastic-test-utils"; // eslint-disable-next-line import/no-internal-modules import { Counter } from "@fluid-private/stochastic-test-utils/internal/test/utils"; import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; @@ -21,7 +21,6 @@ import execa from "execa"; import { type Client, hasStashData } from "../clientLoading.js"; import type { - BaseOperation, ChangeConnectionState, ClientSpec, DDSFuzzHarnessEvents, @@ -33,7 +32,6 @@ import type { } from "../ddsFuzzHarness.js"; import { defaultDDSFuzzSuiteOptions, - generateTestSeeds, mixinAttach, mixinClientSelection, mixinNewClient, @@ -1057,50 +1055,5 @@ describe("DDS Fuzz Harness", () => { assert.equal(runResults.stats.failures, 0); }); }); - - describe("generateTestSeeds", () => { - const testCount = 100; - - it("should generate seeds for short stress mode", () => { - const seeds = generateTestSeeds(testCount, StressMode.Short); - assert.strictEqual(seeds.length, testCount); - assert.deepStrictEqual( - seeds, - Array.from({ length: testCount }, (_, i) => i), - ); - }); - - it("should generate seeds for normal stress mode", () => { - const seeds = generateTestSeeds(testCount, StressMode.Normal); - assert.strictEqual(seeds.length, testCount); - assert.deepStrictEqual( - seeds, - Array.from({ length: testCount }, (_, i) => i), - ); - }); - - it("should generate seeds for long stress mode", () => { - const seeds = generateTestSeeds(testCount, StressMode.Long); - assert.strictEqual(seeds.length, testCount * 2); - // Check that seeds are incrementing - for (let i = 1; i < seeds.length; i++) { - assert.strictEqual(seeds[i], seeds[i - 1] + 1); - } - }); - - it("should generate different seeds for different runs of long stress mode", () => { - const seeds1 = generateTestSeeds(testCount, StressMode.Long); - const seeds2 = generateTestSeeds(testCount, StressMode.Long); - // If this test is ever flaky, consider running multiple trials (as the starting seed is random, sometimes they could be legitimately the same) - assert.notDeepStrictEqual(seeds1, seeds2); - }); - - it("should have all seeds within valid range for long stress mode", () => { - const seeds = generateTestSeeds(testCount, StressMode.Long); - for (const seed of seeds) { - assert.ok(seed >= 0 && seed <= Number.MAX_SAFE_INTEGER); - } - }); - }); }); }); diff --git a/packages/dds/test-dds-utils/src/test/sharedNothing.ts b/packages/dds/test-dds-utils/src/test/sharedNothing.ts index f9feef743922..894609b04b43 100644 --- a/packages/dds/test-dds-utils/src/test/sharedNothing.ts +++ b/packages/dds/test-dds-utils/src/test/sharedNothing.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. */ +import type { BaseOperation } from "@fluid-private/stochastic-test-utils"; import type { IChannelAttributes, IChannelFactory, @@ -13,7 +14,7 @@ import type { import { SummaryTreeBuilder } from "@fluidframework/runtime-utils/internal"; import { SharedObject } from "@fluidframework/shared-object-base/internal"; -import type { BaseOperation, ChangeConnectionState, DDSFuzzModel } from "../ddsFuzzHarness.js"; +import type { ChangeConnectionState, DDSFuzzModel } from "../ddsFuzzHarness.js"; /** * Mock DDS which holds no data. diff --git a/packages/runtime/container-runtime/src/test/fuzz/summarizerFuzzSuite.ts b/packages/runtime/container-runtime/src/test/fuzz/summarizerFuzzSuite.ts index d6fc60f03695..44fb3ed56de6 100644 --- a/packages/runtime/container-runtime/src/test/fuzz/summarizerFuzzSuite.ts +++ b/packages/runtime/container-runtime/src/test/fuzz/summarizerFuzzSuite.ts @@ -7,7 +7,6 @@ import { strict as assert } from "node:assert"; import { mkdirSync, readFileSync } from "node:fs"; -import path from "node:path"; import { TypedEventEmitter } from "@fluid-internal/client-utils"; import { @@ -18,6 +17,8 @@ import { asyncGeneratorFromArray, createFuzzDescribe, defaultOptions, + getSaveDirectory, + getSaveInfo, makeRandom, performFuzzActionsAsync, } from "@fluid-private/stochastic-test-utils"; @@ -156,10 +157,11 @@ export function createSummarizerFuzzSuite( const describeFuzz = createFuzzDescribe({ defaultTestCount: options.defaultTestCount }); describeFuzz(model.workloadName, ({ testCount }) => { - const directory = getSaveDirectory(model, options); before(() => { - if (directory !== undefined) { - mkdirSync(directory, { recursive: true }); + if (options.saveFailures !== undefined && options.saveFailures !== false) { + mkdirSync(getSaveDirectory(options.saveFailures.directory, model), { + recursive: true, + }); } }); @@ -252,39 +254,6 @@ function runTest( }); } -/** - * @internal - */ -interface HasWorkloadName { - workloadName: string; -} - -function getSaveDirectory( - model: HasWorkloadName, - options: SummarizerFuzzSuiteOptions, -): string | undefined { - if (!options.saveFailures) { - return undefined; - } - const workloadFriendly = model.workloadName.replace(/[\s_]+/g, "-").toLowerCase(); - return path.join(options.saveFailures.directory, workloadFriendly); -} - -function getSaveInfo( - model: HasWorkloadName, - options: SummarizerFuzzSuiteOptions, - seed: number, -): SaveInfo { - const directory = getSaveDirectory(model, options); - if (!directory) { - return { saveOnFailure: false, saveOnSuccess: false }; - } - return { - saveOnFailure: { path: path.join(directory, `${seed}.json`) }, - saveOnSuccess: false, - }; -} - type InternalOptions = Omit & { only: Set; skip: Set; diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 536c8a394f02..44c67cd4ed72 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -5,26 +5,31 @@ import { strict as assert } from "node:assert"; import { mkdirSync, readFileSync } from "node:fs"; -import path from "node:path"; import { TypedEventEmitter } from "@fluid-internal/client-utils"; import type { AsyncGenerator, AsyncReducer, BaseFuzzTestState, + BaseOperation, IRandom, + MinimizationTransform, SaveDestination, SaveInfo, } from "@fluid-private/stochastic-test-utils"; import { ExitBehavior, - StressMode, + FuzzTestMinimizer, asyncGeneratorFromArray, chainAsync, createFuzzDescribe, defaultOptions, done, + generateTestSeeds, + getSaveDirectory, + getSaveInfo, interleaveAsync, + isOperationType, makeRandom, performFuzzActionsAsync, saveOpsToFile, @@ -54,8 +59,6 @@ import { } from "@fluidframework/server-local-server"; import { LocalCodeLoader } from "@fluidframework/test-utils/internal"; -import { FuzzTestMinimizer } from "./minification.js"; -import type { MinimizationTransform } from "./minification.js"; import { createRuntimeFactory, StressDataObject, @@ -63,11 +66,6 @@ import { } from "./stressDataObject.js"; import { makeUnreachableCodePathProxy } from "./utils.js"; -const isOperationType = ( - type: O["type"], - op: BaseOperation, -): op is O => op.type === type; - export interface Client { container: IContainer; tag: `client-${number}`; @@ -99,13 +97,6 @@ interface SelectedClientSpec { channelTag: `channel-${number}`; } -/** - * @internal - */ -export interface BaseOperation { - type: number | string; -} - /** * @internal */ @@ -138,39 +129,6 @@ interface Synchronize { clients?: Client[]; } -/** - * @internal - */ -interface HasWorkloadName { - workloadName: string; -} - -function getSaveDirectory(directory: string, model: HasWorkloadName): string { - const workloadFriendly = model.workloadName.replace(/[\s_]+/g, "-").toLowerCase(); - return path.join(directory, workloadFriendly); -} - -function getSavePath(directory: string, model: HasWorkloadName, seed: number): string { - return path.join(getSaveDirectory(directory, model), `${seed}.json`); -} - -function getSaveInfo( - model: HasWorkloadName, - options: LocalServerStressOptions, - seed: number, -): SaveInfo { - return { - saveOnFailure: - options.saveFailures !== undefined - ? { path: getSavePath(options.saveFailures.directory, model, seed) } - : false, - saveOnSuccess: - options.saveSuccesses !== undefined - ? { path: getSavePath(options.saveSuccesses.directory, model, seed) } - : false, - }; -} - export interface LocalServerStressModel< TOperation extends BaseOperation, TState extends LocalServerStressState = LocalServerStressState, @@ -966,8 +924,9 @@ async function runTestForSeed( package: "local-server-stress-tests", }; const codeLoader = new LocalCodeLoader([[codeDetails, createRuntimeFactory()]]); - let tagCount = 0; - const tag: LocalServerStressState["tag"] = (prefix) => `${prefix}-${tagCount++}`; + const tagCount: Partial> = {}; + const tag: LocalServerStressState["tag"] = (prefix) => + `${prefix}-${(tagCount[prefix] = (tagCount[prefix] ??= 0) + 1)}`; const initialClient = await createDetachedClient( localDeltaConnectionServer, codeLoader, @@ -1067,7 +1026,13 @@ function runTest( throw error; } const operations = JSON.parse(file.toString()) as TOperation[]; - const minimizer = new FuzzTestMinimizer(model, options, operations, seed, saveInfo, 3); + const minimizer = new FuzzTestMinimizer( + model.minimizationTransforms, + operations, + saveInfo, + async (generator) => replayTest(model, seed, generator, saveInfo, options), + 3, + ); const minimized = await minimizer.minimize(); await saveOpsToFile(savePath, minimized); @@ -1086,16 +1051,6 @@ function isInternalOptions(options: LocalServerStressOptions): options is Intern return options.only instanceof Set && options.skip instanceof Set; } -/** - * Some reducers require preconditions be met which are validated by their generator. - * The validation can be lost if the generator is not run. - * The primary case where this happens is during minimization. If a reducer detects this - * problem, they can throw this error type, and minimization will consider the current - * test invalid, rather than continuing to test invalid scenarios. - * @internal - */ -export class ReducerPreconditionError extends Error {} - /** * Performs the test again to verify if the DDS still fails with the same error message. * @@ -1104,7 +1059,7 @@ export class ReducerPreconditionError extends Error {} export async function replayTest( ddsModel: LocalServerStressModel, seed: number, - operations: TOperation[], + generator: AsyncGenerator, saveInfo?: SaveInfo, providedOptions?: Partial, ): Promise { @@ -1120,38 +1075,12 @@ export async function replayTest( const model = { ..._model, // We lose some type safety here because the options interface isn't generic - generatorFactory: (): AsyncGenerator => - asyncGeneratorFromArray(operations), + generatorFactory: () => generator, }; await runTestForSeed(model, options, seed, saveInfo); } -function generateTestSeeds(testCount: number, stressMode: StressMode): number[] { - switch (stressMode) { - case StressMode.Short: - case StressMode.Normal: { - // Deterministic, fixed seeds - return Array.from({ length: testCount }, (_, i) => i); - } - - case StressMode.Long: { - // Non-deterministic, random seeds - const random = makeRandom(); - const longModeFactor = 2; - const initialSeed = random.integer( - 0, - Number.MAX_SAFE_INTEGER - longModeFactor * testCount, - ); - return Array.from({ length: testCount * longModeFactor }, (_, i) => initialSeed + i); - } - - default: { - throw new Error(`Unsupported stress mode: ${stressMode}`); - } - } -} - /** * Creates a suite of eventual consistency tests for a particular DDS model. * @internal diff --git a/packages/test/stochastic-test-utils/src/combineReducers.ts b/packages/test/stochastic-test-utils/src/combineReducers.ts index cd57b32e78b7..d6a898bd647f 100644 --- a/packages/test/stochastic-test-utils/src/combineReducers.ts +++ b/packages/test/stochastic-test-utils/src/combineReducers.ts @@ -7,11 +7,26 @@ import { assert } from "@fluidframework/core-utils/internal"; import { AsyncReducer, BaseFuzzTestState, Reducer } from "./types.js"; +/** + * @internal + */ +export interface BaseOperation { + type: number | string; +} + +/** + * @internal + */ +export const isOperationType = ( + type: O["type"], + op: BaseOperation, +): op is O => op.type === type; + /** * @internal */ export function combineReducers< - TOperation extends { type: string | number }, + TOperation extends BaseOperation, TState extends BaseFuzzTestState, >( reducerMap: { @@ -33,7 +48,7 @@ export function combineReducers< * @internal */ export function combineReducersAsync< - TOperation extends { type: string | number }, + TOperation extends BaseOperation, TState extends BaseFuzzTestState, >( reducerMap: { @@ -50,3 +65,13 @@ export function combineReducersAsync< return newState; }; } + +/** + * Some reducers require preconditions be met which are validated by their generator. + * The validation can be lost if the generator is not run. + * The primary case where this happens is during minimization. If a reducer detects this + * problem, they can throw this error type, and minimization will consider the current + * test invalid, rather than continuing to test invalid scenarios. + * @internal + */ +export class ReducerPreconditionError extends Error {} diff --git a/packages/test/stochastic-test-utils/src/describeFuzz.ts b/packages/test/stochastic-test-utils/src/describeFuzz.ts index 3ffdfd7345a0..d44f2d06851a 100644 --- a/packages/test/stochastic-test-utils/src/describeFuzz.ts +++ b/packages/test/stochastic-test-utils/src/describeFuzz.ts @@ -5,6 +5,8 @@ import process from "process"; +import { makeRandom } from "./random.js"; + function createSuite( tests: (this: Mocha.Suite, args: TArgs) => void, args: TArgs, @@ -165,3 +167,31 @@ export const describeFuzz: DescribeFuzz = createFuzzDescribe(); * @internal */ export const describeStress: DescribeStress = createFuzzDescribe(); + +/** + * @internal + */ +export function generateTestSeeds(testCount: number, stressMode: StressMode): number[] { + switch (stressMode) { + case StressMode.Short: + case StressMode.Normal: { + // Deterministic, fixed seeds + return Array.from({ length: testCount }, (_, i) => i); + } + + case StressMode.Long: { + // Non-deterministic, random seeds + const random = makeRandom(); + const longModeFactor = 2; + const initialSeed = random.integer( + 0, + Number.MAX_SAFE_INTEGER - longModeFactor * testCount, + ); + return Array.from({ length: testCount * longModeFactor }, (_, i) => initialSeed + i); + } + + default: { + throw new Error(`Unsupported stress mode: ${stressMode}`); + } + } +} diff --git a/packages/test/stochastic-test-utils/src/index.ts b/packages/test/stochastic-test-utils/src/index.ts index a03b2a63e844..45b376d5810c 100644 --- a/packages/test/stochastic-test-utils/src/index.ts +++ b/packages/test/stochastic-test-utils/src/index.ts @@ -3,7 +3,12 @@ * Licensed under the MIT License. */ -export { combineReducers, combineReducersAsync } from "./combineReducers.js"; +export { + combineReducers, + combineReducersAsync, + BaseOperation, + isOperationType, +} from "./combineReducers.js"; export { createFuzzDescribe, CreateMochaSuite, @@ -14,6 +19,7 @@ export { describeStress, FuzzDescribeOptions, FuzzSuiteArguments, + generateTestSeeds, MochaSuiteWithArguments, StressSuiteArguments, StressMode, @@ -41,12 +47,19 @@ export { SpaceEfficientWordMarkovChain, WordSpacing, } from "./markovChain.js"; +export { FuzzTestMinimizer, MinimizationTransform } from "./minification.js"; export { performFuzzActions, performFuzzActionsAsync, saveOpsToFile, } from "./performActions.js"; export { makeRandom } from "./random.js"; +export { + SaveOptions, + getSaveInfo, + getSaveDirectory, + HasWorkloadName, +} from "./results.js"; export { AcceptanceCondition, AsyncGenerator, diff --git a/packages/test/local-server-stress-tests/src/minification.ts b/packages/test/stochastic-test-utils/src/minification.ts similarity index 84% rename from packages/test/local-server-stress-tests/src/minification.ts rename to packages/test/stochastic-test-utils/src/minification.ts index f61840db247b..1a65d4b31ea7 100644 --- a/packages/test/local-server-stress-tests/src/minification.ts +++ b/packages/test/stochastic-test-utils/src/minification.ts @@ -3,17 +3,9 @@ * Licensed under the MIT License. */ -import { TypedEventEmitter } from "@fluid-internal/client-utils"; -import type { SaveInfo } from "@fluid-private/stochastic-test-utils"; -import { makeRandom } from "@fluid-private/stochastic-test-utils"; - -import type { - BaseOperation, - LocalServerStressHarnessEvents, - LocalServerStressModel, - LocalServerStressOptions, -} from "./localServerStressHarness.js"; -import { ReducerPreconditionError, replayTest } from "./localServerStressHarness.js"; +import { ReducerPreconditionError, type BaseOperation } from "./combineReducers.js"; +import { makeRandom } from "./random.js"; +import { type SaveInfo, type AsyncGenerator, done } from "./types.js"; /** * A function which takes in an operation and modifies it by reference to be more @@ -43,21 +35,22 @@ import { ReducerPreconditionError, replayTest } from "./localServerStressHarness * @internal */ export type MinimizationTransform = (op: TOperation) => void; - +/** + * @internal + */ export class FuzzTestMinimizer { private initialError?: { message: string; op: BaseOperation }; private readonly transforms: MinimizationTransform[]; private readonly random = makeRandom(); constructor( - readonly ddsModel: LocalServerStressModel, - readonly providedOptions: Partial, + minimizationTransforms: MinimizationTransform[] | undefined, readonly operations: TOperation[], - readonly seed: number, readonly saveInfo: SaveInfo, + readonly replayTest: (generator: AsyncGenerator) => Promise, readonly numIterations: number = 1000, ) { - this.transforms = ddsModel.minimizationTransforms ?? []; + this.transforms = minimizationTransforms ?? []; } async minimize(): Promise { @@ -191,22 +184,17 @@ export class FuzzTestMinimizer { * to avoid dealing with transforms that would result in invalid ops */ private async assertFails(): Promise { - const emitter = (this.providedOptions.emitter ??= - new TypedEventEmitter()); - let lastOp: BaseOperation = { type: "___none___" }; - const lastOpTracker = (op: BaseOperation): void => { - lastOp = op; + const operationsIterator = this.operations[Symbol.iterator](); + const generator: AsyncGenerator = async () => { + const val = operationsIterator.next(); + if (val.done === true) { + return done; + } + return (lastOp = val.value); }; - emitter.on("operationStart", lastOpTracker); try { - await replayTest( - this.ddsModel, - this.seed, - this.operations, - undefined, - this.providedOptions, - ); + await this.replayTest(generator); return false; } catch (error: unknown) { if ( @@ -239,8 +227,6 @@ export class FuzzTestMinimizer { return ( message === this.initialError.message && this.initialError.op.type === lastOp.type ); - } finally { - emitter.off("operation", lastOpTracker); } } } diff --git a/packages/test/stochastic-test-utils/src/results.ts b/packages/test/stochastic-test-utils/src/results.ts new file mode 100644 index 000000000000..f5786c2369bb --- /dev/null +++ b/packages/test/stochastic-test-utils/src/results.ts @@ -0,0 +1,55 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import path from "node:path"; + +import type { SaveInfo } from "./types.js"; + +/** + * @internal + */ +export interface HasWorkloadName { + workloadName: string; +} + +/** + * @internal + */ +export function getSaveDirectory(directory: string, model: HasWorkloadName): string { + const workloadFriendly = model.workloadName.replace(/[\s_]+/g, "-").toLowerCase(); + return path.join(directory, workloadFriendly); +} + +function getSavePath(directory: string, model: HasWorkloadName, seed: number): string { + return path.join(getSaveDirectory(directory, model), `${seed}.json`); +} + +/** + * @internal + */ +export interface SaveOptions { + saveFailures?: undefined | false | { directory: string }; + saveSuccesses?: undefined | false | { directory: string }; +} + +/** + * @internal + */ +export function getSaveInfo( + model: HasWorkloadName, + options: SaveOptions, + seed: number, +): SaveInfo { + return { + saveOnFailure: + options.saveFailures !== undefined && options.saveFailures !== false + ? { path: getSavePath(options.saveFailures.directory, model, seed) } + : false, + saveOnSuccess: + options.saveSuccesses !== undefined && options.saveSuccesses !== false + ? { path: getSavePath(options.saveSuccesses.directory, model, seed) } + : false, + }; +} diff --git a/packages/test/stochastic-test-utils/src/test/generateTestSeeds.spec.ts b/packages/test/stochastic-test-utils/src/test/generateTestSeeds.spec.ts new file mode 100644 index 000000000000..2be5536965da --- /dev/null +++ b/packages/test/stochastic-test-utils/src/test/generateTestSeeds.spec.ts @@ -0,0 +1,53 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "assert"; + +import { generateTestSeeds, StressMode } from "../describeFuzz.js"; + +describe("generateTestSeeds", () => { + const testCount = 100; + + it("should generate seeds for short stress mode", () => { + const seeds = generateTestSeeds(testCount, StressMode.Short); + assert.strictEqual(seeds.length, testCount); + assert.deepStrictEqual( + seeds, + Array.from({ length: testCount }, (_, i) => i), + ); + }); + + it("should generate seeds for normal stress mode", () => { + const seeds = generateTestSeeds(testCount, StressMode.Normal); + assert.strictEqual(seeds.length, testCount); + assert.deepStrictEqual( + seeds, + Array.from({ length: testCount }, (_, i) => i), + ); + }); + + it("should generate seeds for long stress mode", () => { + const seeds = generateTestSeeds(testCount, StressMode.Long); + assert.strictEqual(seeds.length, testCount * 2); + // Check that seeds are incrementing + for (let i = 1; i < seeds.length; i++) { + assert.strictEqual(seeds[i], seeds[i - 1] + 1); + } + }); + + it("should generate different seeds for different runs of long stress mode", () => { + const seeds1 = generateTestSeeds(testCount, StressMode.Long); + const seeds2 = generateTestSeeds(testCount, StressMode.Long); + // If this test is ever flaky, consider running multiple trials (as the starting seed is random, sometimes they could be legitimately the same) + assert.notDeepStrictEqual(seeds1, seeds2); + }); + + it("should have all seeds within valid range for long stress mode", () => { + const seeds = generateTestSeeds(testCount, StressMode.Long); + for (const seed of seeds) { + assert.ok(seed >= 0 && seed <= Number.MAX_SAFE_INTEGER); + } + }); +}); From 1aef1461019ab9c2947767341cf231932efbb963 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Fri, 21 Feb 2025 09:34:33 -0800 Subject: [PATCH 51/54] Update packages/test/local-server-stress-tests/src/utils.ts --- packages/test/local-server-stress-tests/package.json | 2 +- packages/test/local-server-stress-tests/src/utils.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/test/local-server-stress-tests/package.json b/packages/test/local-server-stress-tests/package.json index ce9dc706e433..aff4da77b64e 100644 --- a/packages/test/local-server-stress-tests/package.json +++ b/packages/test/local-server-stress-tests/package.json @@ -1,6 +1,6 @@ { "name": "@fluid-internal/local-server-stress-tests", - "version": "2.22.0", + "version": "2.23.0", "private": true, "description": "Stress tests that can only run against the local server", "homepage": "https://fluidframework.com", diff --git a/packages/test/local-server-stress-tests/src/utils.ts b/packages/test/local-server-stress-tests/src/utils.ts index 910dc88c2fea..419b8fb0f6bf 100644 --- a/packages/test/local-server-stress-tests/src/utils.ts +++ b/packages/test/local-server-stress-tests/src/utils.ts @@ -7,9 +7,7 @@ export function makeUnreachableCodePathProxy(name: string): T // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return new Proxy({} as T, { get: (): never => { - throw new Error( - `Unexpected read of '${name}:' this indicates a bug in the DDS eventual consistency harness.`, - ); + throw new Error(`Unexpected read of '${name}:' this indicates a bug in the harness.`); }, }); } From c58203940e3bfbb17fcd68becad171efd53919d3 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Mon, 24 Feb 2025 10:21:58 -0800 Subject: [PATCH 52/54] fix merge --- packages/test/local-server-stress-tests/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/test/local-server-stress-tests/package.json b/packages/test/local-server-stress-tests/package.json index aff4da77b64e..a84dfc2e787b 100644 --- a/packages/test/local-server-stress-tests/package.json +++ b/packages/test/local-server-stress-tests/package.json @@ -63,7 +63,7 @@ "@fluid-private/test-dds-utils": "workspace:~", "@fluidframework/aqueduct": "workspace:~", "@fluidframework/build-common": "^2.0.3", - "@fluidframework/build-tools": "^0.51.0", + "@fluidframework/build-tools": "^0.53.0", "@fluidframework/container-definitions": "workspace:~", "@fluidframework/container-loader": "workspace:~", "@fluidframework/container-runtime": "workspace:~", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4436caf7f0d8..3c414bc619cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13750,8 +13750,8 @@ importers: specifier: ^2.0.3 version: 2.0.3 '@fluidframework/build-tools': - specifier: ^0.51.0 - version: 0.51.0(@types/node@18.19.67) + specifier: ^0.53.0 + version: 0.53.0(@types/node@18.19.67) '@fluidframework/container-definitions': specifier: workspace:~ version: link:../../common/container-definitions From 08d39d9667065e448a8223e7c580f7d4c6776eea Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 25 Feb 2025 17:04:51 -0800 Subject: [PATCH 53/54] Update packages/test/local-server-stress-tests/package.json Co-authored-by: Abram Sanderson --- packages/test/local-server-stress-tests/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/test/local-server-stress-tests/package.json b/packages/test/local-server-stress-tests/package.json index a84dfc2e787b..d6f6da2c3b5c 100644 --- a/packages/test/local-server-stress-tests/package.json +++ b/packages/test/local-server-stress-tests/package.json @@ -105,8 +105,9 @@ "fluidBuild": { "tasks": { "build:test": [ - "^tsc", - "^api-extractor:commonjs" + "...", + "@fluidframework/sequence#build:test" + "@fluidframework/map#build:test" ] } }, From 08aa9f02015d3eaef4b6efad4075b87b1c1decd1 Mon Sep 17 00:00:00 2001 From: Tony Murphy Date: Tue, 25 Feb 2025 18:05:56 -0800 Subject: [PATCH 54/54] clean up unused options and code --- .../src/localServerStressHarness.ts | 114 +++++------------- .../src/test/localServerStress.spec.ts | 13 +- 2 files changed, 34 insertions(+), 93 deletions(-) diff --git a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts index 44c67cd4ed72..ff69c7e02643 100644 --- a/packages/test/local-server-stress-tests/src/localServerStressHarness.ts +++ b/packages/test/local-server-stress-tests/src/localServerStressHarness.ts @@ -6,7 +6,6 @@ import { strict as assert } from "node:assert"; import { mkdirSync, readFileSync } from "node:fs"; -import { TypedEventEmitter } from "@fluid-internal/client-utils"; import type { AsyncGenerator, AsyncReducer, @@ -78,7 +77,7 @@ export interface Client { export interface LocalServerStressState extends BaseFuzzTestState { localDeltaConnectionServer: ILocalDeltaConnectionServer; codeLoader: ICodeDetailsLoader; - containerUrl: string | undefined; + validationClient: Client; random: IRandom; clients: Client[]; client: Client; @@ -227,7 +226,7 @@ export interface LocalServerStressOptions { * operations with whatever strategy is appropriate. * This is useful for nudging test cases towards a particular pattern of clients joining. */ - clientJoinOptions?: { + clientJoinOptions: { /** * The maximum number of clients that will ever be added to the test. * @remarks Due to current mock limitations, clients will only ever be added to the collaboration session, @@ -258,38 +257,8 @@ export interface LocalServerStressOptions { */ detachedStartOptions: { numOpsBeforeAttach: number; - rehydrateDisabled?: true; - attachingBeforeRehydrateDisable?: true; }; - /** - * Defines whether or not ops can be submitted with handles. - */ - handleGenerationDisabled: boolean; - - /** - * Event emitter which allows hooking into interesting points of DDS harness execution. - * Test authors that want to subscribe to any of these events should create a `TypedEventEmitter`, - * do so, and pass it in when creating the suite. - * - * @example - * - * ```typescript - * const emitter = new TypedEventEmitter(); - * emitter.on("clientCreate", (client) => { - * // Casting is necessary as the event typing isn't parameterized with each DDS type. - * const myDDS = client.channel as MyDDSType; - * // Do what you want with `myDDS`, e.g. subscribe to change events, add logging, etc. - * }); - * const options = { - * ...defaultLocalServerStressSuiteOptions, - * emitter, - * }; - * createLocalServerStressSuite(model, options); - * ``` - */ - emitter: TypedEventEmitter; - /** * Strategy for validating eventual consistency of DDSes. * In random mode, each generated operation has the specified probability to instead be a synchronization point @@ -312,11 +281,6 @@ export interface LocalServerStressOptions { */ reconnectProbability: number; - /** - * Each non-synchronization option has this probability of rebasing the current batch before sending it. - */ - rebaseProbability: number; - /** * Seed which should be replayed from disk. * @@ -396,14 +360,15 @@ const defaultLocalServerStressSuiteOptions: LocalServerStressOptions = { detachedStartOptions: { numOpsBeforeAttach: 5, }, - handleGenerationDisabled: true, - emitter: new TypedEventEmitter(), numberOfClients: 3, + clientJoinOptions: { + clientAddProbability: 0.01, + maxNumberOfClients: 6, + }, only: [], skip: [], parseOperations: (serialized: string) => JSON.parse(serialized) as BaseOperation[], - reconnectProbability: 0, - rebaseProbability: 0, + reconnectProbability: 0.01, saveFailures: undefined, saveSuccesses: undefined, validationStrategy: { type: "random", probability: 0.05 }, @@ -427,9 +392,8 @@ function mixinAddRemoveClient< return async ( state: TState, ): Promise => { - const { clients, random, isDetached, containerUrl } = state; + const { clients, random, isDetached, validationClient } = state; if ( - containerUrl !== undefined && options.clientJoinOptions !== undefined && !isDetached && random.bool(options.clientJoinOptions.clientAddProbability) @@ -442,9 +406,11 @@ function mixinAddRemoveClient< } if (clients.length < options.clientJoinOptions.maxNumberOfClients) { + const url = await validationClient.container.getAbsoluteUrl(""); + assert(url !== undefined, "url for client must exist"); return { type: "addClient", - url: containerUrl, + url, clientTag: state.tag("client"), } satisfies AddClient; } @@ -503,10 +469,6 @@ function mixinAttach { const { numOpsBeforeAttach } = options.detachedStartOptions; - if (numOpsBeforeAttach === 0) { - // not wrapping the reducer/generator in this case makes stepping through the harness slightly less painful. - return model as LocalServerStressModel; - } const attachOp = async (): Promise => { return { type: "attach" }; }; @@ -544,20 +506,17 @@ function mixinAttach = async (state, operation) => { // TODO: Only synchronize listed clients if specified if (isSynchronizeOp(operation)) { - const connectedClients = state.clients.filter((client) => { + const { clients, validationClient } = state; + + const connectedClients = clients.filter((client) => { if (client.container.closed || client.container.disposed === true) { throw new Error(`Client ${client.tag} is closed`); } return client.container.connectionState !== ConnectionState.Disconnected; }); + connectedClients.push(validationClient); const rejects = new Map void)[]>( connectedClients.map((c) => [c, []]), @@ -731,13 +693,12 @@ function mixinSynchronization< } if (connectedClients.length > 0) { - const readonlyChannel = connectedClients[0]; for (const client of connectedClients) { try { - await model.validateConsistency(readonlyChannel, client); + await model.validateConsistency(validationClient, client); } catch (error: unknown) { if (error instanceof Error) { - error.message = `Comparing client ${readonlyChannel.tag} vs client ${client.tag}\n${error.message}`; + error.message = `Comparing client ${validationClient.tag} vs client ${client.tag}\n${error.message}`; } throw error; } @@ -918,7 +879,6 @@ async function runTestForSeed( ): Promise { const random = makeRandom(seed); - const startDetached = options.detachedStartOptions.numOpsBeforeAttach !== 0; const localDeltaConnectionServer = LocalDeltaConnectionServer.create(); const codeDetails: IFluidCodeDetails = { package: "local-server-stress-tests", @@ -927,47 +887,32 @@ async function runTestForSeed( const tagCount: Partial> = {}; const tag: LocalServerStressState["tag"] = (prefix) => `${prefix}-${(tagCount[prefix] = (tagCount[prefix] ??= 0) + 1)}`; - const initialClient = await createDetachedClient( + + const detachedClient = await createDetachedClient( localDeltaConnectionServer, codeLoader, codeDetails, tag("client"), ); - const clients: Client[] = [initialClient]; - let containerUrl: string | undefined; - if (!startDetached) { - await initialClient.container.attach(createLocalResolverCreateNewRequest("stress")); - const url = (containerUrl = await initialClient.container.getAbsoluteUrl("")); - assert(url !== undefined, "attached container must have url"); - clients.push( - ...(await Promise.all( - Array.from({ length: options.numberOfClients - 1 }, async (_, i) => - loadClient(localDeltaConnectionServer, codeLoader, tag("client"), url), - ), - )), - ); - } + const initialState: LocalServerStressState = { - clients, + clients: [detachedClient], localDeltaConnectionServer, codeLoader, random, + validationClient: detachedClient, client: makeUnreachableCodePathProxy("client"), datastore: makeUnreachableCodePathProxy("datastore"), channel: makeUnreachableCodePathProxy("channel"), - isDetached: startDetached, - containerUrl, + isDetached: true, tag, }; - options.emitter.emit("testStart", initialState); - let operationCount = 0; const generator = model.generatorFactory(); const finalState = await performFuzzActionsAsync( generator, async (state, operation) => { - options.emitter.emit("operationStart", operation); operationCount++; return model.reducer(state, operation); }, @@ -979,9 +924,8 @@ async function runTestForSeed( // this usually indicates an error on the part of the test author. assert(operationCount > 0, "Generator should have produced at least one operation."); - options.emitter.emit("testEnd", finalState); - finalState.clients.forEach((c) => c.container.dispose()); + finalState.validationClient.container.dispose(); } function runTest( diff --git a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts index dbd521d36b89..70678d4994bc 100644 --- a/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts +++ b/packages/test/local-server-stress-tests/src/test/localServerStress.spec.ts @@ -37,6 +37,9 @@ const reducer = combineReducersAsync({ }, uploadBlob: async (state, op) => // this will hang if we are offline due to disconnect, so we don't wait for blob upload + // this could potentially cause problems with replay if the blob upload doesn't finish + // before its handle is used. this hasn't been seen in practice, but nothing but timing and + // the fact that we assume local server is fast prevents it. void state.datastore.uploadBlob(op.tag, state.random.string(state.random.integer(1, 16))), DDSModelOp: DDSModelOpReducer, }); @@ -89,18 +92,12 @@ describe("Local Server Stress", () => { createLocalServerStressSuite(model, { defaultTestCount: 100, - numberOfClients: 3, - clientJoinOptions: { - maxNumberOfClients: 6, - clientAddProbability: 0.1, - }, - reconnectProbability: 0.1, // skipMinimization: true, // Uncomment to replay a particular seed. - // replay: 98, + // replay: 93, // only: [99], saveFailures, // saveSuccesses, - skip: [67, 77], + skip: [93], }); });