1 /**
2  * ae.utils.statequeue
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <ae@cy.md>
12  */
13 
14 module ae.utils.statequeue;
15 
16 import core.time;
17 
18 import ae.net.asockets;
19 import ae.utils.promise;
20 
21 /**
22    Let `f(x)` be an expensive operation which changes something to
23    (or towards) state `x`.  At most one `f` call may be in progress at any time.
24    This type orchestrates a series of operations that eventually bring
25    the state to some goal, while allowing the goal to change at any time.
26  */
27 struct StateQueue(State)
28 {
29 private:
30 	bool prodPending;
31 
32 	// Flag to aid the debug invariant.
33 	// Normally (oldState == newState) == (currentTransition is null).
34 	// One exception to that is when a transition from an invalid state started,
35 	// and setCurrentState was called during the transition,
36 	// so we're "transitioning" from an invalid to an invalid state.
37 	debug bool stateWasReset;
38 
39 	void enqueueProd()
40 	{
41 		if (prodPending)
42 			return;
43 		prodPending = true;
44 		socketManager.onNextTick(&prod);
45 	}
46 
47 	void prod()
48 	{
49 		prodPending = false;
50 		if (currentTransition)
51 			return; // Will be picked up in onComplete
52 		assert(oldState == newState);
53 		if (newState == goalState)
54 			return; // Already in the goal state
55 		// Start a new transition
56 		newState = goalState;
57 		currentTransition = stateFunc(goalState)
58 			.then(&onComplete, &onFail);
59 	}
60 
61 	void onComplete(State resultState)
62 	{
63 		assert(currentTransition);
64 		debug assert(oldState != newState || stateWasReset);
65 		debug stateWasReset = false;
66 		oldState = newState = resultState;
67 		currentTransition = null;
68 
69 		if (newState == goalState)
70 			goalPromise.fulfill();
71 
72 		prod();
73 	}
74 
75 	void onFail(Exception e)
76 	{
77 		assert(currentTransition);
78 		debug assert(oldState != newState || stateWasReset);
79 		debug stateWasReset = false;
80 
81 		// TODO: The logic here may be incomplete.
82 		// For now, just notify the application that
83 		// the transition failed.
84 		goalPromise.reject(e);
85 	}
86 
87 public:
88 	@disable this();
89 
90 	/// The asynchronous implementation function which actually changes the state.
91 	Promise!State delegate(State) stateFunc;
92 
93 	/// The state that any current change is moving away from.
94 	State oldState;
95 
96 	/// The state that any current change is moving towards.
97 	State newState;
98 
99 	/// The final state that we want to be in.
100 	State goalState;
101 
102 	/// The promise that will be fulfilled when we reach the goal state.
103 	Promise!void goalPromise;
104 
105 	/// The current state transition.
106 	Promise!void currentTransition;
107 
108 	debug invariant
109 	{
110 		if (currentTransition)
111 			assert(oldState != newState || stateWasReset);
112 		else
113 			assert(oldState == newState);
114 	}
115 
116 	/// Constructor.
117 	this(
118 		/// The function implementing the state transition operation.
119 		/// Accepts the goal state, and returns a promise which is
120 		/// the resulting (ideally but necessarily, the goal) state.
121 		Promise!State delegate(State) stateFunc,
122 		/// The initial state.
123 		State initialState = State.init,
124 	)
125 	{
126 		this.stateFunc = stateFunc;
127 		this.oldState = this.newState = this.goalState = initialState;
128 		goalPromise = resolve();
129 	}
130 
131 	/// Set the goal state.  Starts off a transition operation if needed.
132 	/// Returns a promise that will be fulfilled when we reach the goal state,
133 	/// or rejected if the goal state changes before it is reached.
134 	Promise!void setGoal(State state)
135 	{
136 		if (goalState != state)
137 		{
138 			if (currentTransition || newState != goalState)
139 				goalPromise.reject(new Exception("Goal changed"));
140 			goalPromise = new Promise!void;
141 
142 			this.goalState = state;
143 			enqueueProd();
144 		}
145 		return goalPromise;
146 	}
147 
148 	/// Can be used to indicate that the state has been changed externally
149 	/// (e.g. to some "invalid"/"dirty" state).
150 	/// If a transition operation is already in progress, assume that it will
151 	/// change the state to the given state instead of its actual goal.
152 	void setCurrentState(State state = State.init)
153 	{
154 		if (currentTransition)
155 		{
156 			newState = state;
157 			debug stateWasReset = true;
158 		}
159 		else
160 		{
161 			oldState = newState = state;
162 			enqueueProd();
163 		}
164 	}
165 }
166 
167 // Test changing the goal multiple times per tick
168 debug(ae_unittest) unittest
169 {
170 	import ae.utils.promise.timing : sleep;
171 
172 	int state, workDone;
173 	Promise!int changeState(int i)
174 	{
175 		return sleep(1.msecs).then({
176 			workDone++;
177 			state = i;
178 			return i;
179 		});
180 	}
181 
182 	auto q = StateQueue!int(&changeState);
183 	assert(workDone == 0);
184 
185 	q.setGoal(1).ignoreResult();
186 	q.setGoal(2).ignoreResult();
187 	socketManager.loop();
188 	assert(state == 2 && workDone == 1);
189 }
190 
191 // Test incremental transitions towards the goal
192 debug(ae_unittest) unittest
193 {
194 	import ae.utils.promise.timing : sleep;
195 
196 	int state, workDone;
197 	Promise!int changeState(int i)
198 	{
199 		return sleep(1.msecs).then({
200 			workDone++;
201 			auto nextState = state + 1;
202 			state = nextState;
203 			return nextState;
204 		});
205 	}
206 
207 	auto q = StateQueue!int(&changeState);
208 	assert(workDone == 0);
209 
210 	q.setGoal(3).ignoreResult();
211 	socketManager.loop();
212 	assert(state == 3 && workDone == 3);
213 }