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
26 changes: 24 additions & 2 deletions packages/@aws-cdk/aws-iotevents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,35 @@ const input = new iotevents.Input(this, 'MyInput', {
attributeJsonPaths: ['payload.deviceId', 'payload.temperature'],
});

const onlineState = new iotevents.State({
stateName: 'online',
const normalState = new iotevents.State({
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
stateName: 'normal',
onEnter: [{
eventName: 'test-event',
condition: iotevents.Expression.currentInput(input),
}],
});
const coldState = new iotevents.State({
stateName: 'cold',
});

// transit to coldState when temperature is 10
normalState.transitionTo({
eventName: 'to_coldState',
nextState: coldState,
condition: iotevents.Expression.eq(
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
iotevents.Expression.fromString('10'),
),
});
// transit to normalState when temperature is 20
coldState.transitionTo({
eventName: 'to_normalState',
nextState: normalState,
condition: iotevents.Expression.eq(
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
iotevents.Expression.fromString('20'),
),
});

new iotevents.DetectorModel(this, 'MyDetectorModel', {
detectorModelName: 'test-detector-model', // optional
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-iotevents/lib/detector-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export class DetectorModel extends Resource implements IDetectorModel {
key: props.detectorKey,
detectorModelDefinition: {
initialStateName: props.initialState.stateName,
states: [props.initialState._toStateJson()],
states: props.initialState._getStatesJson(),
},
roleArn: role.roleArn,
});
Expand Down
25 changes: 23 additions & 2 deletions packages/@aws-cdk/aws-iotevents/lib/event.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
import { Expression } from './expression';
import { State } from './state';

/**
* Specifies the actions to be performed when the condition evaluates to TRUE.
* The base interface for events.
*/
export interface Event {
interface IEventBase {
/**
* The name of the event.
*/
readonly eventName: string;
}

/**
* Specifies the actions to be performed when the condition evaluates to TRUE.
*/
export interface IEvent extends IEventBase {
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
/**
* The Boolean expression that, when TRUE, causes the actions to be performed.
*
* @default - none (the actions are always executed)
*/
readonly condition?: Expression;
}

/**
* Specifies the state transition and the actions to be performed when the condition evaluates to TRUE.
*/
export interface ITransitionEvent extends IEventBase {
/**
* The Boolean expression that, when TRUE, causes the state transition and the actions to be performed.
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
*/
readonly condition: Expression;

/**
* The next state to transit to. When the resuld of condition expression is TRUE, the state is transited.
*/
readonly nextState: State;
}
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
48 changes: 41 additions & 7 deletions packages/@aws-cdk/aws-iotevents/lib/state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Event } from './event';
import { IEvent, ITransitionEvent } from './event';
import { CfnDetectorModel } from './iotevents.generated';

/**
Expand All @@ -16,7 +16,7 @@ export interface StateProps {
*
* @default - events on enter will not be set
*/
readonly onEnter?: Event[];
readonly onEnter?: IEvent[];
}

/**
Expand All @@ -28,21 +28,46 @@ export class State {
*/
public readonly stateName: string;

private transitionEvents: ITransitionEvent[] = []
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved

constructor(private readonly props: StateProps) {
this.stateName = props.stateName;
}

/**
* Return the state property JSON
* Add a transition event to the state.
*
* @param transitionEvent the transition event that triggered if condition is evaluated to TRUE
*/
public transitionTo(transitionEvent:ITransitionEvent) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I actually don't like the API of this method right now.

  1. I think the state we are transitioning to should be the first positional argument, not in ITransitionEvent.
  2. I think eventName should be optional, and we should generate an event name by default by combining the names of the States.
  3. We call this second argument that's an object Options by convention in the CDK, so this should be TransitionOptions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So smart! 🤩 Thank you!

yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
this.transitionEvents.push(transitionEvent);
}

/**
* Return the JSON of the states that is transited to.
* This function is called recursively and collect the states.
*
* @internal
*/
public _toStateJson(): CfnDetectorModel.StateProperty {
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
public _getStatesJson(states: CfnDetectorModel.StateProperty[] = []): CfnDetectorModel.StateProperty[] {
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
const { stateName, onEnter } = this.props;
return {

if (states.some(s => s.stateName === stateName)) {
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
return states;
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't we use a Set here, to check if we haven't see the same state twice?

Also, we're using stateName to do the check, but I'm 99% sure we don't do any uniqueness checking on state names, so this is not correct - we should actually compare the State objects.

So, to sum up, this should use a Set<State> for checking the duplicates, and return a list of CfnDetectorModel.StateProperty. Don't do any accumulation of the result through mutating a variable - instead, just return an empty list if the given State was already visited, and that will handle this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm amaging that codes became clean by this and below advices!

}

const newStates: CfnDetectorModel.StateProperty[] = [...states, {
stateName,
onInput: {
transitionEvents: this.transitionEvents.length > 0 ?
getTransitionEventJson(this.transitionEvents) : undefined,
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
},
onEnter: onEnter && { events: getEventJson(onEnter) },
};
}];

return this.transitionEvents.reduce((acc, transitionEvent) => {
return transitionEvent.nextState._getStatesJson(acc);
}, newStates);
Copy link
Contributor

Choose a reason for hiding this comment

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

I have to say, I don't understand why do we have to call this method recursively? Why does translating all this.transitionEvents Events isn't good enough?

Copy link
Contributor Author

@yamatatsu yamatatsu Feb 2, 2022

Choose a reason for hiding this comment

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

OK.
IoT Events can construct state dependency graph, for example the following image:
image

When this case, firstState has only one transition firstState_to_secondState and can collect only one state. Therefore, it is necessary to recursively collect states by tracing transitions.

}

/**
Expand All @@ -55,11 +80,20 @@ export class State {
}
}

function getEventJson(events: Event[]): CfnDetectorModel.EventProperty[] {
function getEventJson(events: IEvent[]): CfnDetectorModel.EventProperty[] {
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
return events.map(e => {
return {
eventName: e.eventName,
condition: e.condition?.evaluate(),
};
});
}
function getTransitionEventJson(events: ITransitionEvent[]): CfnDetectorModel.TransitionEventProperty[] {
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
return events.map(e => {
return {
eventName: e.eventName,
condition: e.condition.evaluate(),
nextState: e.nextState.stateName,
};
});
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
}
57 changes: 57 additions & 0 deletions packages/@aws-cdk/aws-iotevents/test/detector-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,63 @@ test('can set multiple events to State', () => {
});
});

test('can set states with transitions', () => {
// WHEN
const firstState = new iotevents.State({
stateName: 'firstState',
onEnter: [{
eventName: 'test-eventName',
condition: iotevents.Expression.fromString('test-eventCondition'),
}],
});
const secondState = new iotevents.State({
stateName: 'secondState',
});

firstState.transitionTo({
eventName: 'firstToSecond',
nextState: secondState,
condition: iotevents.Expression.fromString('test-eventCondition-12'),
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
});
secondState.transitionTo({
eventName: 'secondToFirst',
nextState: firstState,
condition: iotevents.Expression.fromString('test-eventCondition-21'),
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
});
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved

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: 'firstToSecond',
NextState: 'secondState',
Condition: 'test-eventCondition-12',
}],
},
},
{
StateName: 'secondState',
OnInput: {
TransitionEvents: [{
EventName: 'secondToFirst',
NextState: 'firstState',
Condition: 'test-eventCondition-21',
}],
},
},
],
},
});
});

test('can set role', () => {
// WHEN
const role = iam.Role.fromRoleArn(stack, 'test-role', 'arn:aws:iam::123456789012:role/ForTest');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"Type": "AWS::IoTEvents::DetectorModel",
"Properties": {
"DetectorModelDefinition": {
"InitialStateName": "online",
"InitialStateName": "firstState",
"States": [
{
"OnEnter": {
Expand All @@ -63,7 +63,50 @@
}
]
},
"StateName": "online"
"OnInput": {
"TransitionEvents": [
{
"Condition": {
"Fn::Join": [
"",
[
"$input.",
{
"Ref": "MyInput08947B23"
},
".payload.temperature == 12"
]
]
},
"EventName": "firstToSecond",
"NextState": "secondState"
}
]
},
"StateName": "firstState"
},
{
"OnInput": {
"TransitionEvents": [
{
"Condition": {
"Fn::Join": [
"",
[
"$input.",
{
"Ref": "MyInput08947B23"
},
".payload.temperature == 21"
]
]
},
"EventName": "secondToFirst",
"NextState": "firstState"
}
]
},
"StateName": "secondState"
}
]
},
Expand Down
28 changes: 25 additions & 3 deletions packages/@aws-cdk/aws-iotevents/test/integ.detector-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ class TestStack extends cdk.Stack {
attributeJsonPaths: ['payload.deviceId', 'payload.temperature'],
});

const onlineState = new iotevents.State({
stateName: 'online',
const firstState = new iotevents.State({
yamatatsu marked this conversation as resolved.
Show resolved Hide resolved
stateName: 'firstState',
onEnter: [{
eventName: 'test-event',
// meaning `condition: 'currentInput("test_input") && $input.test_input.payload.temperature == 31.5'`
Expand All @@ -24,13 +24,35 @@ class TestStack extends cdk.Stack {
),
}],
});
const secondState = new iotevents.State({
stateName: 'secondState',
});

// 1st => 2nd
firstState.transitionTo({
eventName: 'firstToSecond',
nextState: secondState,
condition: iotevents.Expression.eq(
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
iotevents.Expression.fromString('12'),
),
});
// 2st => 1st
secondState.transitionTo({
eventName: 'secondToFirst',
nextState: firstState,
condition: iotevents.Expression.eq(
iotevents.Expression.inputAttribute(input, 'payload.temperature'),
iotevents.Expression.fromString('21'),
),
});

new iotevents.DetectorModel(this, 'MyDetectorModel', {
detectorModelName: 'test-detector-model',
description: 'test-detector-model-description',
evaluationMethod: iotevents.EventEvaluation.SERIAL,
detectorKey: 'payload.deviceId',
initialState: onlineState,
initialState: firstState,
});
}
}
Expand Down