From 3ac667a47257aeaf57221ab06d4e5e3b5495e35e Mon Sep 17 00:00:00 2001 From: Tatsuya Yamamoto Date: Tue, 8 Feb 2022 06:09:00 +0900 Subject: [PATCH] feat(iotevents): support transition events (#18768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR allow IoT Events detector model to transit to multiple states. This PR is in roadmap of #17711. スクリーンショット 2022-02-02 0 38 10 Following image is the graph displayed on AWS console when this integ test deployed. [Compared to the previous version](https://github.com/aws/aws-cdk/pull/18049), you can see that the state transitions are now represented. ![image](https://user-images.githubusercontent.com/11013683/151999116-5b3b36b0-d2b9-4e3a-9483-824dc0618f4b.png) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-iotevents/README.md | 25 +++- .../aws-iotevents/lib/detector-model.ts | 11 +- packages/@aws-cdk/aws-iotevents/lib/state.ts | 116 ++++++++++++++++-- .../aws-iotevents/test/detector-model.test.ts | 115 ++++++++++++++++- .../test/integ.detector-model.expected.json | 43 +++++++ .../test/integ.detector-model.ts | 18 +++ 6 files changed, 305 insertions(+), 23 deletions(-) diff --git a/packages/@aws-cdk/aws-iotevents/README.md b/packages/@aws-cdk/aws-iotevents/README.md index 864833049b402..809bac071ef7d 100644 --- a/packages/@aws-cdk/aws-iotevents/README.md +++ b/packages/@aws-cdk/aws-iotevents/README.md @@ -54,20 +54,39 @@ const input = new iotevents.Input(this, 'MyInput', { attributeJsonPaths: ['payload.deviceId', 'payload.temperature'], }); -const onlineState = new iotevents.State({ - stateName: 'online', +const warmState = new iotevents.State({ + stateName: 'warm', onEnter: [{ eventName: 'test-event', condition: iotevents.Expression.currentInput(input), }], }); +const coldState = new iotevents.State({ + stateName: 'cold', +}); + +// transit to coldState when temperature is 10 +warmState.transitionTo(coldState, { + eventName: 'to_coldState', // optional property, default by combining the names of the States + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('10'), + ), +}); +// transit to warmState when temperature is 20 +coldState.transitionTo(warmState, { + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('20'), + ), +}); new iotevents.DetectorModel(this, 'MyDetectorModel', { detectorModelName: 'test-detector-model', // optional description: 'test-detector-model-description', // optional property, default is none evaluationMethod: iotevents.EventEvaluation.SERIAL, // optional property, default is iotevents.EventEvaluation.BATCH detectorKey: 'payload.deviceId', // optional property, default is none and single detector instance will be created and all inputs will be routed to it - initialState: onlineState, + initialState: warmState, }); ``` diff --git a/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts b/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts index a35b1efc30d23..35128bc4531e6 100644 --- a/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts +++ b/packages/@aws-cdk/aws-iotevents/lib/detector-model.ts @@ -21,13 +21,14 @@ export interface IDetectorModel extends IResource { */ export enum EventEvaluation { /** - * When setting to SERIAL, variables are updated and event conditions are evaluated in the order - * that the events are defined. + * When setting to BATCH, variables within a state are updated and events within a state are + * performed only after all event conditions are evaluated. */ BATCH = 'BATCH', + /** - * When setting to BATCH, variables within a state are updated and events within a state are - * performed only after all event conditions are evaluated. + * When setting to SERIAL, variables are updated and event conditions are evaluated in the order + * that the events are defined. */ SERIAL = 'SERIAL', } @@ -123,7 +124,7 @@ export class DetectorModel extends Resource implements IDetectorModel { key: props.detectorKey, detectorModelDefinition: { initialStateName: props.initialState.stateName, - states: [props.initialState._toStateJson()], + states: props.initialState._collectStateJsons(new Set()), }, roleArn: role.roleArn, }); diff --git a/packages/@aws-cdk/aws-iotevents/lib/state.ts b/packages/@aws-cdk/aws-iotevents/lib/state.ts index 129d3395776ad..67ee6a32802ec 100644 --- a/packages/@aws-cdk/aws-iotevents/lib/state.ts +++ b/packages/@aws-cdk/aws-iotevents/lib/state.ts @@ -1,6 +1,45 @@ import { Event } from './event'; +import { Expression } from './expression'; import { CfnDetectorModel } from './iotevents.generated'; +/** + * Properties for options of state transition. + */ +export interface TransitionOptions { + /** + * The name of the event. + * + * @default string combining the names of the States as `${originStateName}_to_${targetStateName}` + */ + readonly eventName?: string; + + /** + * The condition that is used to determine to cause the state transition and the actions. + * When this was evaluated to TRUE, the state transition and the actions are triggered. + */ + readonly when: Expression; +} + +/** + * Specifies the state transition and the actions to be performed when the condition evaluates to TRUE. + */ +interface TransitionEvent { + /** + * The name of the event. + */ + readonly eventName: string; + + /** + * The Boolean expression that, when TRUE, causes the state transition and the actions to be performed. + */ + readonly condition: Expression; + + /** + * The next state to transit to. When the resuld of condition expression is TRUE, the state is transited. + */ + readonly nextState: State; +} + /** * Properties for defining a state of a detector. */ @@ -28,21 +67,51 @@ export class State { */ public readonly stateName: string; + private readonly transitionEvents: TransitionEvent[] = []; + constructor(private readonly props: StateProps) { this.stateName = props.stateName; } /** - * Return the state property JSON. + * Add a transition event to the state. + * The transition event will be triggered if condition is evaluated to TRUE. + * + * @param targetState the state that will be transit to when the event triggered + * @param options transition options including the condition that causes the state transition + */ + public transitionTo(targetState: State, options: TransitionOptions) { + const alreadyAdded = this.transitionEvents.some(transitionEvent => transitionEvent.nextState === targetState); + if (alreadyAdded) { + throw new Error(`State '${this.stateName}' already has a transition defined to '${targetState.stateName}'`); + } + + this.transitionEvents.push({ + eventName: options.eventName ?? `${this.stateName}_to_${targetState.stateName}`, + nextState: targetState, + condition: options.when, + }); + } + + /** + * Collect states in dependency gragh that constructed by state transitions, + * and return the JSONs of the states. + * This function is called recursively and collect the states. * * @internal */ - public _toStateJson(): CfnDetectorModel.StateProperty { - const { stateName, onEnter } = this.props; - return { - stateName, - onEnter: onEnter && { events: getEventJson(onEnter) }, - }; + public _collectStateJsons(collectedStates: Set): CfnDetectorModel.StateProperty[] { + if (collectedStates.has(this)) { + return []; + } + collectedStates.add(this); + + return [ + this.toStateJson(), + ...this.transitionEvents.flatMap(transitionEvent => { + return transitionEvent.nextState._collectStateJsons(collectedStates); + }), + ]; } /** @@ -53,13 +122,34 @@ export class State { public _onEnterEventsHaveAtLeastOneCondition(): boolean { return this.props.onEnter?.some(event => event.condition) ?? false; } -} -function getEventJson(events: Event[]): CfnDetectorModel.EventProperty[] { - return events.map(e => { + private toStateJson(): CfnDetectorModel.StateProperty { + const { onEnter } = this.props; return { - eventName: e.eventName, - condition: e.condition?.evaluate(), + stateName: this.stateName, + onEnter: onEnter && { events: toEventsJson(onEnter) }, + onInput: { + transitionEvents: toTransitionEventsJson(this.transitionEvents), + }, }; - }); + } +} + +function toEventsJson(events: Event[]): CfnDetectorModel.EventProperty[] { + return events.map(event => ({ + eventName: event.eventName, + condition: event.condition?.evaluate(), + })); +} + +function toTransitionEventsJson(transitionEvents: TransitionEvent[]): CfnDetectorModel.TransitionEventProperty[] | undefined { + if (transitionEvents.length === 0) { + return undefined; + } + + return transitionEvents.map(transitionEvent => ({ + eventName: transitionEvent.eventName, + condition: transitionEvent.condition.evaluate(), + nextState: transitionEvent.nextState.stateName, + })); } diff --git a/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts b/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts index a15ba6a986049..c90a10cf34374 100644 --- a/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts +++ b/packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts @@ -4,8 +4,10 @@ import * as cdk from '@aws-cdk/core'; import * as iotevents from '../lib'; let stack: cdk.Stack; +let input: iotevents.IInput; beforeEach(() => { stack = new cdk.Stack(); + input = iotevents.Input.fromInputName(stack, 'MyInput', 'test-input'); }); test('Default property', () => { @@ -137,6 +139,89 @@ test('can set multiple events to State', () => { }); }); +test('can set states with transitions', () => { + // GIVEN + const firstState = new iotevents.State({ + stateName: 'firstState', + onEnter: [{ + eventName: 'test-eventName', + condition: iotevents.Expression.currentInput(input), + }], + }); + const secondState = new iotevents.State({ + stateName: 'secondState', + }); + const thirdState = new iotevents.State({ + stateName: 'thirdState', + }); + + // WHEN + // transition as 1st -> 2nd + firstState.transitionTo(secondState, { + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('12'), + ), + }); + // transition as 2nd -> 1st, make circular reference + secondState.transitionTo(firstState, { + eventName: 'secondToFirst', + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('21'), + ), + }); + // transition as 2nd -> 3rd, to test recursive calling + secondState.transitionTo(thirdState, { + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('23'), + ), + }); + + new iotevents.DetectorModel(stack, 'MyDetectorModel', { + initialState: firstState, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', { + DetectorModelDefinition: { + States: [ + { + StateName: 'firstState', + OnInput: { + TransitionEvents: [{ + EventName: 'firstState_to_secondState', + NextState: 'secondState', + Condition: '$input.test-input.payload.temperature == 12', + }], + }, + }, + { + StateName: 'secondState', + OnInput: { + TransitionEvents: [ + { + EventName: 'secondToFirst', + NextState: 'firstState', + Condition: '$input.test-input.payload.temperature == 21', + }, + { + EventName: 'secondState_to_thirdState', + NextState: 'thirdState', + Condition: '$input.test-input.payload.temperature == 23', + }, + ], + }, + }, + { + StateName: 'thirdState', + }, + ], + }, + }); +}); + test('can set role', () => { // WHEN const role = iam.Role.fromRoleArn(stack, 'test-role', 'arn:aws:iam::123456789012:role/ForTest'); @@ -191,10 +276,37 @@ test('cannot create without event', () => { }).toThrow('Detector Model must have at least one Input with a condition'); }); +test('cannot create transitions that transit to duprecated target state', () => { + const firstState = new iotevents.State({ + stateName: 'firstState', + onEnter: [{ + eventName: 'test-eventName', + }], + }); + const secondState = new iotevents.State({ + stateName: 'secondState', + }); + + firstState.transitionTo(secondState, { + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('12.1'), + ), + }); + + expect(() => { + firstState.transitionTo(secondState, { + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('12.2'), + ), + }); + }).toThrow("State 'firstState' already has a transition defined to 'secondState'"); +}); + describe('Expression', () => { test('currentInput', () => { // WHEN - const input = iotevents.Input.fromInputName(stack, 'MyInput', 'test-input'); new iotevents.DetectorModel(stack, 'MyDetectorModel', { initialState: new iotevents.State({ stateName: 'test-state', @@ -223,7 +335,6 @@ describe('Expression', () => { test('inputAttribute', () => { // WHEN - const input = iotevents.Input.fromInputName(stack, 'MyInput', 'test-input'); new iotevents.DetectorModel(stack, 'MyDetectorModel', { initialState: new iotevents.State({ stateName: 'test-state', diff --git a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.expected.json b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.expected.json index 3b1b598427701..888869a41e68e 100644 --- a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.expected.json +++ b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.expected.json @@ -63,7 +63,50 @@ } ] }, + "OnInput": { + "TransitionEvents": [ + { + "Condition": { + "Fn::Join": [ + "", + [ + "$input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature == 12" + ] + ] + }, + "EventName": "online_to_offline", + "NextState": "offline" + } + ] + }, "StateName": "online" + }, + { + "OnInput": { + "TransitionEvents": [ + { + "Condition": { + "Fn::Join": [ + "", + [ + "$input.", + { + "Ref": "MyInput08947B23" + }, + ".payload.temperature == 21" + ] + ] + }, + "EventName": "offline_to_online", + "NextState": "online" + } + ] + }, + "StateName": "offline" } ] }, diff --git a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts index dc90a7d505dbf..5f6d2839f3a93 100644 --- a/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts +++ b/packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts @@ -24,6 +24,24 @@ class TestStack extends cdk.Stack { ), }], }); + const offlineState = new iotevents.State({ + stateName: 'offline', + }); + + // 1st => 2nd + onlineState.transitionTo(offlineState, { + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('12'), + ), + }); + // 2st => 1st + offlineState.transitionTo(onlineState, { + when: iotevents.Expression.eq( + iotevents.Expression.inputAttribute(input, 'payload.temperature'), + iotevents.Expression.fromString('21'), + ), + }); new iotevents.DetectorModel(this, 'MyDetectorModel', { detectorModelName: 'test-detector-model',