Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(iotevents): support transition events #18768

Merged
merged 15 commits into from
Feb 7, 2022
Merged
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',
Comment on lines 23 to 33
Copy link
Contributor Author

@yamatatsu yamatatsu Feb 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry that my funny mistake😓. Let me include it in this PR.

}
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 {
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
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'),
),
});

skinny85 marked this conversation as resolved.
Show resolved Hide resolved
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