Skip to content

Commit e6f4abf

Browse files
committed
Ignore persisted values when features change
Affects individual behaviors on endpoints with persisted values. Storage must be tagged with features, so storage created in previous versions of code will not benefit from this enhancement. Fixes #1161
1 parent 6465702 commit e6f4abf

File tree

3 files changed

+100
-2
lines changed

3 files changed

+100
-2
lines changed

packages/model/src/logic/ModelTraversal.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const OPERATION_DEPTH_LIMIT = 20;
2828

2929
let memos: Memos | undefined;
3030

31+
// Member caches. Only populated for frozen models
32+
const activeMemberCache = new WeakMap<Model, ValueModel[]>();
33+
const conformantMemberCache = new WeakMap<Model, ValueModel[]>();
34+
3135
/**
3236
* This class performs lookups of models in the scope of a specific model. We use a class so the lookup can maintain
3337
* state and guard against circular references.
@@ -526,12 +530,21 @@ export class ModelTraversal {
526530
*
527531
* - If there are multiple applicable members based on conformance the definitions conflict and throw an error
528532
*
533+
* If the model is frozen we cache the return value.
534+
*
529535
* Note that "active" in this case does not imply the member is conformant, only that conflicts are resolved.
530536
*
531537
* Note 2 - members may not be differentiated with conformance rules that rely on field values in this way. That
532538
* will probably never be necessary and would require an entirely different (more complicated) structure.
533539
*/
534540
findActiveMembers(scope: Model & { members: PropertyModel[] }, conformantOnly: boolean, cluster?: ClusterModel) {
541+
const cache = Object.isFrozen(scope) ? (conformantOnly ? conformantMemberCache : activeMemberCache) : undefined;
542+
543+
const cached = cache?.get(scope);
544+
if (cached) {
545+
return cached;
546+
}
547+
535548
const features = cluster?.featureNames ?? new FeatureSet();
536549
const supportedFeatures = cluster?.supportedFeatures ?? new FeatureSet();
537550

@@ -564,7 +577,13 @@ export class ModelTraversal {
564577
selectedMembers[member.name] = member;
565578
}
566579

567-
return Object.values(selectedMembers);
580+
const result = Object.values(selectedMembers);
581+
582+
if (cache) {
583+
cache.set(scope, result);
584+
}
585+
586+
return result;
568587
}
569588

570589
/**

packages/node/src/behavior/state/managed/Datasource.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { ReadOnlyTransaction } from "../transaction/Tx.js";
2828

2929
const logger = Logger.get("Datasource");
3030

31+
const FEATURES_KEY = "__features__";
32+
3133
/**
3234
* Datasource manages the canonical root of a state tree. The "state" property of a Behavior is a reference to a
3335
* Datasource.
@@ -206,6 +208,7 @@ interface Internals extends Datasource.Options {
206208
values: Val.Struct;
207209
version: number;
208210
sessions?: Map<ValueSupervisor.Session, SessionContext>;
211+
featuresKey?: string;
209212
interactionObserver(): MaybePromise<void>;
210213
}
211214

@@ -223,11 +226,28 @@ interface CommitChanges {
223226
function configure(options: Datasource.Options): Internals {
224227
const values = new options.type() as Val.Struct;
225228

229+
let storedValues = options.store?.initialValues;
230+
231+
let featuresKey: undefined | string;
232+
if (options.supervisor.featureMap.children.length) {
233+
featuresKey = [...options.supervisor.supportedFeatures].join(",");
234+
if (storedValues?.[FEATURES_KEY] !== undefined && storedValues[FEATURES_KEY] !== featuresKey) {
235+
logger.warn(
236+
`Ignoring persisted values for ${options.path} because features changed from "${values.featuresKey}" to "${featuresKey}"`,
237+
);
238+
storedValues = undefined;
239+
}
240+
}
241+
226242
const initialValues = {
227243
...options.defaults,
228-
...options.store?.initialValues,
244+
...storedValues,
229245
};
230246

247+
if (FEATURES_KEY in initialValues) {
248+
delete initialValues[FEATURES_KEY];
249+
}
250+
231251
for (const key in initialValues) {
232252
values[key] = initialValues[key];
233253
}
@@ -236,6 +256,7 @@ function configure(options: Datasource.Options): Internals {
236256
...options,
237257
version: Crypto.getRandomUInt32(),
238258
values: values,
259+
featuresKey,
239260

240261
interactionObserver() {
241262
function handleObserverError(error: any) {
@@ -567,6 +588,10 @@ function createSessionContext(resource: Resource, internals: Internals, session:
567588
return;
568589
}
569590

591+
if (internals.featuresKey !== undefined) {
592+
persistent[FEATURES_KEY] = internals.featuresKey;
593+
}
594+
570595
return internals.store?.set(session.transaction, persistent);
571596
}
572597

packages/node/test/node/ServerNodeTest.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { DescriptorBehavior } from "#behaviors/descriptor";
1010
import { PumpConfigurationAndControlServer } from "#behaviors/pump-configuration-and-control";
1111
import { GeneralCommissioning } from "#clusters/general-commissioning";
1212
import { PumpConfigurationAndControl } from "#clusters/pump-configuration-and-control";
13+
import { ColorTemperatureLightDevice } from "#devices/color-temperature-light";
14+
import { ExtendedColorLightDevice } from "#devices/extended-color-light";
1315
import { LightSensorDevice } from "#devices/light-sensor";
1416
import { OnOffLightDevice } from "#devices/on-off-light";
1517
import { PumpDevice } from "#devices/pump";
@@ -28,6 +30,9 @@ import {
2830
MockUdpChannel,
2931
NetworkSimulator,
3032
PrivateKey,
33+
StorageBackendMemory,
34+
StorageManager,
35+
StorageService,
3136
} from "#general";
3237
import { ServerNode } from "#node/ServerNode.js";
3338
import { AttestationCertificateManager, CertificationDeclarationManager, FabricManager } from "#protocol";
@@ -492,6 +497,55 @@ describe("ServerNode", () => {
492497
});
493498
});
494499
});
500+
501+
it("is resilient to conformance changes that affect persisted data", async () => {
502+
const environment = new Environment("test");
503+
const service = environment.get(StorageService);
504+
505+
// Configure storage that will survive node replacement
506+
const storage = new StorageManager(new StorageBackendMemory());
507+
storage.close = () => {};
508+
await storage.initialize();
509+
service.open = () => Promise.resolve(storage);
510+
511+
// Initialize a node with extended color light, ensure levelX persists
512+
{
513+
const node = new MockServerNode({ id: "node0", environment });
514+
515+
await node.construction.ready;
516+
517+
const originalEndpoint = await node.add(ExtendedColorLightDevice, {
518+
id: "foo",
519+
number: 1,
520+
colorControl: {
521+
startUpColorTemperatureMireds: 0,
522+
coupleColorTempToLevelMinMireds: 0,
523+
},
524+
});
525+
526+
await originalEndpoint.set({ colorControl: { currentX: 12 } });
527+
528+
await node.close();
529+
}
530+
531+
// Initialize a node with color temp light, levelX won't be supported
532+
{
533+
const node = new MockServerNode({ id: "node0", environment });
534+
535+
await node.construction.ready;
536+
537+
await node.add(ColorTemperatureLightDevice, {
538+
id: "foo",
539+
number: 1,
540+
colorControl: {
541+
startUpColorTemperatureMireds: 0,
542+
coupleColorTempToLevelMinMireds: 0,
543+
},
544+
});
545+
546+
await node.close();
547+
}
548+
});
495549
});
496550

497551
async function almostCommission(node?: MockServerNode, number = 0) {

0 commit comments

Comments
 (0)