Skip to content

Commit

Permalink
feat(iotevents): support transition events (#18768)
Browse files Browse the repository at this point in the history
This PR allow IoT Events detector model to transit to multiple states.
This PR is in roadmap of #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](#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*
  • Loading branch information
yamatatsu committed Feb 7, 2022
1 parent 066919a commit ccc1988
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 23 deletions.
25 changes: 22 additions & 3 deletions packages/@aws-cdk/aws-iotevents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
```

Expand Down
11 changes: 6 additions & 5 deletions packages/@aws-cdk/aws-iotevents/lib/detector-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand Down Expand Up @@ -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<State>()),
},
roleArn: role.roleArn,
});
Expand Down
116 changes: 103 additions & 13 deletions packages/@aws-cdk/aws-iotevents/lib/state.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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<State>): CfnDetectorModel.StateProperty[] {
if (collectedStates.has(this)) {
return [];
}
collectedStates.add(this);

return [
this.toStateJson(),
...this.transitionEvents.flatMap(transitionEvent => {
return transitionEvent.nextState._collectStateJsons(collectedStates);
}),
];
}

/**
Expand All @@ -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,
}));
}
115 changes: 113 additions & 2 deletions packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading

0 comments on commit ccc1988

Please sign in to comment.