Skip to content

Commit 83efac2

Browse files
committed
Add retry state transition proof
1 parent b1b3d07 commit 83efac2

4 files changed

Lines changed: 468 additions & 7 deletions

File tree

example/build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ tasks.register('api2DevdockTusRetryOffsetRecovery', JavaExec) {
4242
workingDir = rootProject.projectDir
4343
}
4444

45+
tasks.register('api2DevdockTusRetryStateTransitions', JavaExec) {
46+
classpath = sourceSets.main.runtimeClasspath
47+
mainClass = 'io.tus.java.example.Api2DevdockTusRetryStateTransitions'
48+
workingDir = rootProject.projectDir
49+
}
50+
4551
tasks.register('api2DevdockTusRequestLifecycleHooks', JavaExec) {
4652
classpath = sourceSets.main.runtimeClasspath
4753
mainClass = 'io.tus.java.example.Api2DevdockTusRequestLifecycleHooks'
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
package io.tus.java.example;
2+
3+
import io.tus.java.client.ProtocolException;
4+
import io.tus.java.client.TusClient;
5+
import io.tus.java.client.TusExecutor;
6+
import io.tus.java.client.TusRequestLifecycleHooks;
7+
import io.tus.java.client.TusURLMemoryStore;
8+
import io.tus.java.client.TusUpload;
9+
import io.tus.java.client.TusUploader;
10+
import org.json.JSONArray;
11+
import org.json.JSONObject;
12+
13+
import java.io.ByteArrayInputStream;
14+
import java.io.IOException;
15+
import java.net.HttpURLConnection;
16+
import java.net.URL;
17+
import java.util.ArrayList;
18+
import java.util.List;
19+
import java.util.Map;
20+
21+
public final class Api2DevdockTusRetryStateTransitions {
22+
private static final String UPLOAD_OFFSET_HEADER_NAME = "Upload-Offset";
23+
24+
/**
25+
* Run the API2 devdock TUS retry-state transitions example.
26+
*
27+
* @param args ignored
28+
*/
29+
public static void main(String[] args) {
30+
try {
31+
System.setProperty("http.strictPostRedirect", "true");
32+
33+
final JSONObject scenario = Api2DevdockScenario.loadScenario();
34+
final JSONObject result = uploadWithRetryStateTransitions(scenario);
35+
Api2DevdockScenario.writeResult(result);
36+
37+
System.out.println(
38+
"Java TUS SDK devdock scenario "
39+
+ scenario.getString("scenarioId")
40+
+ " observed "
41+
+ result.getInt("eventCount")
42+
+ " retry event(s) for "
43+
+ result.getString("uploadUrl")
44+
);
45+
} catch (Exception e) {
46+
e.printStackTrace();
47+
System.exit(1);
48+
}
49+
}
50+
51+
private static JSONObject uploadWithRetryStateTransitions(JSONObject scenario)
52+
throws IOException, ProtocolException {
53+
final JSONObject conformanceScenario = Api2DevdockScenario.conformanceScenario(scenario);
54+
final byte[] content = Api2DevdockScenario.conformanceInputSourceBytes(
55+
conformanceScenario
56+
);
57+
final URL endpointOrigin = new URL(Api2DevdockScenario.conformanceInputStringOption(
58+
conformanceScenario,
59+
"endpointUrl"
60+
));
61+
final Map<String, String> metadata = Api2DevdockScenario.conformanceInputStringMapOption(
62+
conformanceScenario,
63+
"metadata"
64+
);
65+
final int[] retryDelays = conformanceRetryDelays(conformanceScenario);
66+
final RetryStateObserver observer = new RetryStateObserver(
67+
retryDecisions(conformanceScenario),
68+
retryDelays
69+
);
70+
final JSONObject completion = conformanceScenario.getJSONObject("completion");
71+
72+
try (Api2DevdockTusConformanceServer conformanceServer =
73+
new Api2DevdockTusConformanceServer(conformanceScenario, endpointOrigin)) {
74+
final TusClient client = new TusClient();
75+
client.setUploadCreationURL(conformanceServer.endpointUrl());
76+
client.enableResuming(new TusURLMemoryStore());
77+
78+
final TusUpload upload = uploadFor(scenario, content, metadata);
79+
final long[] lastAcceptedOffset = new long[]{0};
80+
final RetryStateExecutor executor = new RetryStateExecutor(
81+
client,
82+
upload,
83+
content.length,
84+
observer
85+
);
86+
executor.setDelays(retryDelays);
87+
client.setRequestLifecycleHooks(new TusRequestLifecycleHooks(
88+
null,
89+
new TusRequestLifecycleHooks.AfterResponse() {
90+
@Override
91+
public void afterResponse(
92+
TusRequestLifecycleHooks.RequestContext context
93+
) {
94+
final long acceptedOffset = readAcceptedOffset(context.getConnection());
95+
if (acceptedOffset <= lastAcceptedOffset[0]) {
96+
return;
97+
}
98+
99+
lastAcceptedOffset[0] = acceptedOffset;
100+
executor.resetAfterProgress();
101+
}
102+
}
103+
));
104+
105+
if (!executor.makeAttempts()) {
106+
throw new IOException("retry state transition upload was interrupted");
107+
}
108+
observer.assertComplete();
109+
conformanceServer.assertExhausted();
110+
111+
final JSONObject result = conformanceServer.result();
112+
result.put("completionKind", completion.getString("kind"));
113+
result.put("errorCalled", false);
114+
result.put("eventCount", observer.events().length());
115+
result.put("events", observer.events());
116+
result.put("requestCount", result.getJSONArray("requestMethods").length());
117+
result.put("successCalled", true);
118+
result.put("uploadUrl", conformanceServer.canonicalUrl(executor.uploadUrl()));
119+
120+
return result;
121+
}
122+
}
123+
124+
private static TusUpload uploadFor(
125+
JSONObject scenario,
126+
byte[] content,
127+
Map<String, String> metadata
128+
) {
129+
final TusUpload upload = new TusUpload();
130+
upload.setInputStream(new ByteArrayInputStream(content));
131+
upload.setSize(content.length);
132+
upload.setFingerprint(scenario.getString("scenarioId") + "-java-retry-state-transitions");
133+
upload.setMetadata(metadata);
134+
return upload;
135+
}
136+
137+
private static int[] conformanceRetryDelays(JSONObject conformanceScenario) {
138+
final JSONArray values = conformanceInputJSONArrayOption(conformanceScenario, "retryDelays");
139+
final int[] result = new int[values.length()];
140+
for (int index = 0; index < values.length(); index++) {
141+
result[index] = values.getInt(index);
142+
}
143+
144+
return result;
145+
}
146+
147+
private static JSONArray conformanceInputJSONArrayOption(
148+
JSONObject conformanceScenario,
149+
String key
150+
) {
151+
final JSONArray entries = conformanceScenario.getJSONArray("inputOptionEntries");
152+
for (int index = 0; index < entries.length(); index++) {
153+
final JSONObject entry = entries.getJSONObject(index);
154+
if (key.equals(entry.getString("key"))) {
155+
return entry.getJSONArray("value");
156+
}
157+
}
158+
159+
throw new IllegalArgumentException("missing conformance input array option " + key);
160+
}
161+
162+
private static List<RetryDecision> retryDecisions(JSONObject conformanceScenario) {
163+
final JSONArray values = conformanceScenario.getJSONArray("retryDecisions");
164+
final List<RetryDecision> result = new ArrayList<RetryDecision>();
165+
for (int index = 0; index < values.length(); index++) {
166+
final JSONObject value = values.getJSONObject(index);
167+
result.add(new RetryDecision(
168+
value.getBoolean("decision"),
169+
value.getInt("retryAttempt")
170+
));
171+
}
172+
173+
return result;
174+
}
175+
176+
private static long readAcceptedOffset(HttpURLConnection connection) {
177+
final String value = connection.getHeaderField(UPLOAD_OFFSET_HEADER_NAME);
178+
if (value == null || value.length() == 0) {
179+
return -1;
180+
}
181+
182+
try {
183+
return Long.parseLong(value);
184+
} catch (NumberFormatException error) {
185+
return -1;
186+
}
187+
}
188+
189+
private static final class RetryStateExecutor extends TusExecutor {
190+
private final TusClient client;
191+
private final TusUpload upload;
192+
private final int requestPayloadSize;
193+
private final RetryStateObserver observer;
194+
private String uploadUrl;
195+
196+
RetryStateExecutor(
197+
TusClient client,
198+
TusUpload upload,
199+
int requestPayloadSize,
200+
RetryStateObserver observer
201+
) {
202+
this.client = client;
203+
this.upload = upload;
204+
this.requestPayloadSize = requestPayloadSize;
205+
this.observer = observer;
206+
}
207+
208+
@Override
209+
protected void makeAttempt() throws ProtocolException, IOException {
210+
final TusUploader uploader = client.resumeOrCreateUpload(upload);
211+
uploader.setChunkSize(requestPayloadSize);
212+
uploader.setRequestPayloadSize(requestPayloadSize);
213+
int uploadedChunkSize;
214+
do {
215+
uploadedChunkSize = uploader.uploadChunk();
216+
} while (uploadedChunkSize > -1);
217+
uploader.finish();
218+
uploadUrl = uploader.getUploadURL().toString();
219+
}
220+
221+
@Override
222+
protected boolean shouldRetry(ProtocolException exception, int retryAttempt) {
223+
return observer.shouldRetry(retryAttempt);
224+
}
225+
226+
@Override
227+
protected boolean shouldRetry(IOException exception, int retryAttempt) {
228+
return observer.shouldRetry(retryAttempt);
229+
}
230+
231+
@Override
232+
protected void onRetryScheduled(int retryAttempt, int delayMillis) {
233+
observer.retryScheduled(retryAttempt, delayMillis);
234+
}
235+
236+
void resetAfterProgress() {
237+
resetRetryAttempts();
238+
}
239+
240+
String uploadUrl() {
241+
if (uploadUrl == null) {
242+
throw new IllegalStateException("retry state transition upload did not expose a URL");
243+
}
244+
245+
return uploadUrl;
246+
}
247+
}
248+
249+
private static final class RetryStateObserver {
250+
private final List<RetryDecision> decisions;
251+
private final JSONArray events;
252+
private final int[] retryDelays;
253+
private int decisionIndex;
254+
255+
RetryStateObserver(List<RetryDecision> decisions, int[] retryDelays) {
256+
this.decisions = decisions;
257+
this.events = new JSONArray();
258+
this.retryDelays = retryDelays;
259+
}
260+
261+
boolean shouldRetry(int retryAttempt) {
262+
if (decisionIndex >= decisions.size()) {
263+
throw new IllegalStateException(
264+
"retry state transition received unexpected retry decision "
265+
+ decisionIndex
266+
);
267+
}
268+
269+
final RetryDecision decision = decisions.get(decisionIndex);
270+
if (retryAttempt != decision.retryAttempt) {
271+
throw new IllegalStateException(
272+
"retry state transition expected retry attempt "
273+
+ decision.retryAttempt
274+
+ ", got "
275+
+ retryAttempt
276+
);
277+
}
278+
279+
events.put(new JSONObject()
280+
.put("decision", decision.decision)
281+
.put("kind", "should-retry")
282+
.put("retryAttempt", retryAttempt));
283+
decisionIndex += 1;
284+
return decision.decision;
285+
}
286+
287+
void retryScheduled(int retryAttempt, int delayMillis) {
288+
if (retryAttempt < 0 || retryAttempt >= retryDelays.length) {
289+
throw new IllegalStateException(
290+
"retry state transition retry attempt "
291+
+ retryAttempt
292+
+ " has no retry delay"
293+
);
294+
}
295+
if (delayMillis != retryDelays[retryAttempt]) {
296+
throw new IllegalStateException(
297+
"retry state transition expected retry delay "
298+
+ retryDelays[retryAttempt]
299+
+ ", got "
300+
+ delayMillis
301+
);
302+
}
303+
304+
events.put(new JSONObject()
305+
.put("delay", delayMillis)
306+
.put("kind", "retry-schedule"));
307+
}
308+
309+
void assertComplete() {
310+
if (decisionIndex == decisions.size()) {
311+
return;
312+
}
313+
314+
throw new IllegalStateException(
315+
"retry state transition expected "
316+
+ decisions.size()
317+
+ " retry decision(s), got "
318+
+ decisionIndex
319+
);
320+
}
321+
322+
JSONArray events() {
323+
return events;
324+
}
325+
}
326+
327+
private static final class RetryDecision {
328+
final boolean decision;
329+
final int retryAttempt;
330+
331+
RetryDecision(boolean decision, int retryAttempt) {
332+
this.decision = decision;
333+
this.retryAttempt = retryAttempt;
334+
}
335+
}
336+
337+
private Api2DevdockTusRetryStateTransitions() {
338+
throw new IllegalStateException("Utility class");
339+
}
340+
}

0 commit comments

Comments
 (0)