Skip to content

Commit 3ac667a

Browse files
yamatatsuTikiTDO
authored andcommitted
feat(iotevents): support transition events (aws#18768)
This PR allow IoT Events detector model to transit to multiple states. This PR is in roadmap of aws#17711. <img width="561" alt="スクリーンショット 2022-02-02 0 38 10" src="https://user-images.githubusercontent.com/11013683/151999891-45afa8e8-57ed-4264-a323-16b84ed35348.png"> Following image is the graph displayed on AWS console when this integ test deployed. [Compared to the previous version](aws#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*
1 parent d3b7212 commit 3ac667a

File tree

6 files changed

+305
-23
lines changed

6 files changed

+305
-23
lines changed

Diff for: packages/@aws-cdk/aws-iotevents/README.md

+22-3
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,39 @@ const input = new iotevents.Input(this, 'MyInput', {
5454
attributeJsonPaths: ['payload.deviceId', 'payload.temperature'],
5555
});
5656

57-
const onlineState = new iotevents.State({
58-
stateName: 'online',
57+
const warmState = new iotevents.State({
58+
stateName: 'warm',
5959
onEnter: [{
6060
eventName: 'test-event',
6161
condition: iotevents.Expression.currentInput(input),
6262
}],
6363
});
64+
const coldState = new iotevents.State({
65+
stateName: 'cold',
66+
});
67+
68+
// transit to coldState when temperature is 10
69+
warmState.transitionTo(coldState, {
70+
eventName: 'to_coldState', // optional property, default by combining the names of the States
71+
when: iotevents.Expression.eq(
72+
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
73+
iotevents.Expression.fromString('10'),
74+
),
75+
});
76+
// transit to warmState when temperature is 20
77+
coldState.transitionTo(warmState, {
78+
when: iotevents.Expression.eq(
79+
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
80+
iotevents.Expression.fromString('20'),
81+
),
82+
});
6483

6584
new iotevents.DetectorModel(this, 'MyDetectorModel', {
6685
detectorModelName: 'test-detector-model', // optional
6786
description: 'test-detector-model-description', // optional property, default is none
6887
evaluationMethod: iotevents.EventEvaluation.SERIAL, // optional property, default is iotevents.EventEvaluation.BATCH
6988
detectorKey: 'payload.deviceId', // optional property, default is none and single detector instance will be created and all inputs will be routed to it
70-
initialState: onlineState,
89+
initialState: warmState,
7190
});
7291
```
7392

Diff for: packages/@aws-cdk/aws-iotevents/lib/detector-model.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ export interface IDetectorModel extends IResource {
2121
*/
2222
export enum EventEvaluation {
2323
/**
24-
* When setting to SERIAL, variables are updated and event conditions are evaluated in the order
25-
* that the events are defined.
24+
* When setting to BATCH, variables within a state are updated and events within a state are
25+
* performed only after all event conditions are evaluated.
2626
*/
2727
BATCH = 'BATCH',
28+
2829
/**
29-
* When setting to BATCH, variables within a state are updated and events within a state are
30-
* performed only after all event conditions are evaluated.
30+
* When setting to SERIAL, variables are updated and event conditions are evaluated in the order
31+
* that the events are defined.
3132
*/
3233
SERIAL = 'SERIAL',
3334
}
@@ -123,7 +124,7 @@ export class DetectorModel extends Resource implements IDetectorModel {
123124
key: props.detectorKey,
124125
detectorModelDefinition: {
125126
initialStateName: props.initialState.stateName,
126-
states: [props.initialState._toStateJson()],
127+
states: props.initialState._collectStateJsons(new Set<State>()),
127128
},
128129
roleArn: role.roleArn,
129130
});

Diff for: packages/@aws-cdk/aws-iotevents/lib/state.ts

+103-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
11
import { Event } from './event';
2+
import { Expression } from './expression';
23
import { CfnDetectorModel } from './iotevents.generated';
34

5+
/**
6+
* Properties for options of state transition.
7+
*/
8+
export interface TransitionOptions {
9+
/**
10+
* The name of the event.
11+
*
12+
* @default string combining the names of the States as `${originStateName}_to_${targetStateName}`
13+
*/
14+
readonly eventName?: string;
15+
16+
/**
17+
* The condition that is used to determine to cause the state transition and the actions.
18+
* When this was evaluated to TRUE, the state transition and the actions are triggered.
19+
*/
20+
readonly when: Expression;
21+
}
22+
23+
/**
24+
* Specifies the state transition and the actions to be performed when the condition evaluates to TRUE.
25+
*/
26+
interface TransitionEvent {
27+
/**
28+
* The name of the event.
29+
*/
30+
readonly eventName: string;
31+
32+
/**
33+
* The Boolean expression that, when TRUE, causes the state transition and the actions to be performed.
34+
*/
35+
readonly condition: Expression;
36+
37+
/**
38+
* The next state to transit to. When the resuld of condition expression is TRUE, the state is transited.
39+
*/
40+
readonly nextState: State;
41+
}
42+
443
/**
544
* Properties for defining a state of a detector.
645
*/
@@ -28,21 +67,51 @@ export class State {
2867
*/
2968
public readonly stateName: string;
3069

70+
private readonly transitionEvents: TransitionEvent[] = [];
71+
3172
constructor(private readonly props: StateProps) {
3273
this.stateName = props.stateName;
3374
}
3475

3576
/**
36-
* Return the state property JSON.
77+
* Add a transition event to the state.
78+
* The transition event will be triggered if condition is evaluated to TRUE.
79+
*
80+
* @param targetState the state that will be transit to when the event triggered
81+
* @param options transition options including the condition that causes the state transition
82+
*/
83+
public transitionTo(targetState: State, options: TransitionOptions) {
84+
const alreadyAdded = this.transitionEvents.some(transitionEvent => transitionEvent.nextState === targetState);
85+
if (alreadyAdded) {
86+
throw new Error(`State '${this.stateName}' already has a transition defined to '${targetState.stateName}'`);
87+
}
88+
89+
this.transitionEvents.push({
90+
eventName: options.eventName ?? `${this.stateName}_to_${targetState.stateName}`,
91+
nextState: targetState,
92+
condition: options.when,
93+
});
94+
}
95+
96+
/**
97+
* Collect states in dependency gragh that constructed by state transitions,
98+
* and return the JSONs of the states.
99+
* This function is called recursively and collect the states.
37100
*
38101
* @internal
39102
*/
40-
public _toStateJson(): CfnDetectorModel.StateProperty {
41-
const { stateName, onEnter } = this.props;
42-
return {
43-
stateName,
44-
onEnter: onEnter && { events: getEventJson(onEnter) },
45-
};
103+
public _collectStateJsons(collectedStates: Set<State>): CfnDetectorModel.StateProperty[] {
104+
if (collectedStates.has(this)) {
105+
return [];
106+
}
107+
collectedStates.add(this);
108+
109+
return [
110+
this.toStateJson(),
111+
...this.transitionEvents.flatMap(transitionEvent => {
112+
return transitionEvent.nextState._collectStateJsons(collectedStates);
113+
}),
114+
];
46115
}
47116

48117
/**
@@ -53,13 +122,34 @@ export class State {
53122
public _onEnterEventsHaveAtLeastOneCondition(): boolean {
54123
return this.props.onEnter?.some(event => event.condition) ?? false;
55124
}
56-
}
57125

58-
function getEventJson(events: Event[]): CfnDetectorModel.EventProperty[] {
59-
return events.map(e => {
126+
private toStateJson(): CfnDetectorModel.StateProperty {
127+
const { onEnter } = this.props;
60128
return {
61-
eventName: e.eventName,
62-
condition: e.condition?.evaluate(),
129+
stateName: this.stateName,
130+
onEnter: onEnter && { events: toEventsJson(onEnter) },
131+
onInput: {
132+
transitionEvents: toTransitionEventsJson(this.transitionEvents),
133+
},
63134
};
64-
});
135+
}
136+
}
137+
138+
function toEventsJson(events: Event[]): CfnDetectorModel.EventProperty[] {
139+
return events.map(event => ({
140+
eventName: event.eventName,
141+
condition: event.condition?.evaluate(),
142+
}));
143+
}
144+
145+
function toTransitionEventsJson(transitionEvents: TransitionEvent[]): CfnDetectorModel.TransitionEventProperty[] | undefined {
146+
if (transitionEvents.length === 0) {
147+
return undefined;
148+
}
149+
150+
return transitionEvents.map(transitionEvent => ({
151+
eventName: transitionEvent.eventName,
152+
condition: transitionEvent.condition.evaluate(),
153+
nextState: transitionEvent.nextState.stateName,
154+
}));
65155
}

Diff for: packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts

+113-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import * as cdk from '@aws-cdk/core';
44
import * as iotevents from '../lib';
55

66
let stack: cdk.Stack;
7+
let input: iotevents.IInput;
78
beforeEach(() => {
89
stack = new cdk.Stack();
10+
input = iotevents.Input.fromInputName(stack, 'MyInput', 'test-input');
911
});
1012

1113
test('Default property', () => {
@@ -137,6 +139,89 @@ test('can set multiple events to State', () => {
137139
});
138140
});
139141

142+
test('can set states with transitions', () => {
143+
// GIVEN
144+
const firstState = new iotevents.State({
145+
stateName: 'firstState',
146+
onEnter: [{
147+
eventName: 'test-eventName',
148+
condition: iotevents.Expression.currentInput(input),
149+
}],
150+
});
151+
const secondState = new iotevents.State({
152+
stateName: 'secondState',
153+
});
154+
const thirdState = new iotevents.State({
155+
stateName: 'thirdState',
156+
});
157+
158+
// WHEN
159+
// transition as 1st -> 2nd
160+
firstState.transitionTo(secondState, {
161+
when: iotevents.Expression.eq(
162+
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
163+
iotevents.Expression.fromString('12'),
164+
),
165+
});
166+
// transition as 2nd -> 1st, make circular reference
167+
secondState.transitionTo(firstState, {
168+
eventName: 'secondToFirst',
169+
when: iotevents.Expression.eq(
170+
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
171+
iotevents.Expression.fromString('21'),
172+
),
173+
});
174+
// transition as 2nd -> 3rd, to test recursive calling
175+
secondState.transitionTo(thirdState, {
176+
when: iotevents.Expression.eq(
177+
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
178+
iotevents.Expression.fromString('23'),
179+
),
180+
});
181+
182+
new iotevents.DetectorModel(stack, 'MyDetectorModel', {
183+
initialState: firstState,
184+
});
185+
186+
// THEN
187+
Template.fromStack(stack).hasResourceProperties('AWS::IoTEvents::DetectorModel', {
188+
DetectorModelDefinition: {
189+
States: [
190+
{
191+
StateName: 'firstState',
192+
OnInput: {
193+
TransitionEvents: [{
194+
EventName: 'firstState_to_secondState',
195+
NextState: 'secondState',
196+
Condition: '$input.test-input.payload.temperature == 12',
197+
}],
198+
},
199+
},
200+
{
201+
StateName: 'secondState',
202+
OnInput: {
203+
TransitionEvents: [
204+
{
205+
EventName: 'secondToFirst',
206+
NextState: 'firstState',
207+
Condition: '$input.test-input.payload.temperature == 21',
208+
},
209+
{
210+
EventName: 'secondState_to_thirdState',
211+
NextState: 'thirdState',
212+
Condition: '$input.test-input.payload.temperature == 23',
213+
},
214+
],
215+
},
216+
},
217+
{
218+
StateName: 'thirdState',
219+
},
220+
],
221+
},
222+
});
223+
});
224+
140225
test('can set role', () => {
141226
// WHEN
142227
const role = iam.Role.fromRoleArn(stack, 'test-role', 'arn:aws:iam::123456789012:role/ForTest');
@@ -191,10 +276,37 @@ test('cannot create without event', () => {
191276
}).toThrow('Detector Model must have at least one Input with a condition');
192277
});
193278

279+
test('cannot create transitions that transit to duprecated target state', () => {
280+
const firstState = new iotevents.State({
281+
stateName: 'firstState',
282+
onEnter: [{
283+
eventName: 'test-eventName',
284+
}],
285+
});
286+
const secondState = new iotevents.State({
287+
stateName: 'secondState',
288+
});
289+
290+
firstState.transitionTo(secondState, {
291+
when: iotevents.Expression.eq(
292+
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
293+
iotevents.Expression.fromString('12.1'),
294+
),
295+
});
296+
297+
expect(() => {
298+
firstState.transitionTo(secondState, {
299+
when: iotevents.Expression.eq(
300+
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
301+
iotevents.Expression.fromString('12.2'),
302+
),
303+
});
304+
}).toThrow("State 'firstState' already has a transition defined to 'secondState'");
305+
});
306+
194307
describe('Expression', () => {
195308
test('currentInput', () => {
196309
// WHEN
197-
const input = iotevents.Input.fromInputName(stack, 'MyInput', 'test-input');
198310
new iotevents.DetectorModel(stack, 'MyDetectorModel', {
199311
initialState: new iotevents.State({
200312
stateName: 'test-state',
@@ -223,7 +335,6 @@ describe('Expression', () => {
223335

224336
test('inputAttribute', () => {
225337
// WHEN
226-
const input = iotevents.Input.fromInputName(stack, 'MyInput', 'test-input');
227338
new iotevents.DetectorModel(stack, 'MyDetectorModel', {
228339
initialState: new iotevents.State({
229340
stateName: 'test-state',

0 commit comments

Comments
 (0)