Skip to content

Commit e8a90c7

Browse files
committed
fix dispatch() primitive cast bug and add tests
1 parent d1b9dae commit e8a90c7

File tree

2 files changed

+232
-1
lines changed

2 files changed

+232
-1
lines changed

client/src/main/java/com/microsoft/durabletask/TaskEntity.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,18 @@ protected Object dispatch(String operationName, Object input) {
277277
* @throws ClassCastException if the result cannot be cast to {@code returnType}
278278
* @see #dispatch(String, Object)
279279
*/
280+
@SuppressWarnings("unchecked")
280281
protected <V> V dispatch(String operationName, Object input, Class<V> returnType) {
281-
return returnType.cast(dispatch(operationName, input));
282+
Object result = dispatch(operationName, input);
283+
if (result == null) {
284+
return null;
285+
}
286+
// Primitive types (int.class, etc.) cannot use Class.cast() with boxed values,
287+
// so we let the unchecked cast handle primitive-to-wrapper conversion.
288+
if (returnType.isPrimitive()) {
289+
return (V) result;
290+
}
291+
return returnType.cast(result);
282292
}
283293

284294
/**

client/src/test/java/com/microsoft/durabletask/TaskEntityTest.java

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,225 @@ void statePersistence_stateIsSavedAfterOperation() throws Exception {
418418
}
419419

420420
// endregion
421+
422+
// region Re-entrant dispatch tests
423+
424+
/**
425+
* Entity that uses dispatch() to compose operations re-entrantly.
426+
*/
427+
static class BonusDepositEntity extends TaskEntity<Integer> {
428+
429+
public void deposit(int amount) {
430+
this.state += amount;
431+
}
432+
433+
public void depositWithBonus(int amount) {
434+
// Re-entrant: calls deposit() twice on the same entity
435+
dispatch("deposit", amount); // main deposit
436+
dispatch("deposit", amount / 10); // 10% bonus
437+
}
438+
439+
public int get() {
440+
return this.state;
441+
}
442+
443+
@Override
444+
protected Integer initializeState(TaskEntityOperation operation) {
445+
return 0;
446+
}
447+
448+
@Override
449+
protected Class<Integer> getStateType() {
450+
return Integer.class;
451+
}
452+
}
453+
454+
/**
455+
* Entity that uses dispatch() with a typed return value.
456+
*/
457+
static class ComputeEntity extends TaskEntity<Integer> {
458+
459+
public int double_value(int input) {
460+
return input * 2;
461+
}
462+
463+
public int quadruple(int input) {
464+
// dispatch → double → then dispatch → double again
465+
int doubled = dispatch("double_value", input, int.class);
466+
return dispatch("double_value", doubled, int.class);
467+
}
468+
469+
@Override
470+
protected Integer initializeState(TaskEntityOperation operation) {
471+
return 0;
472+
}
473+
474+
@Override
475+
protected Class<Integer> getStateType() {
476+
return Integer.class;
477+
}
478+
}
479+
480+
/**
481+
* Entity that uses dispatch() to call an implicit "delete" operation.
482+
*/
483+
static class SelfDeletingEntity extends TaskEntity<String> {
484+
485+
public String resetAndDelete() {
486+
this.state = "resetting";
487+
dispatch("delete");
488+
return "deleted";
489+
}
490+
491+
@Override
492+
protected String initializeState(TaskEntityOperation operation) {
493+
return "initial";
494+
}
495+
496+
@Override
497+
protected Class<String> getStateType() {
498+
return String.class;
499+
}
500+
}
501+
502+
/**
503+
* Entity that tests dispatch() to state-dispatched methods.
504+
*/
505+
static class StateDispatchWithReentrancy extends TaskEntity<MyState> {
506+
507+
public StateDispatchWithReentrancy() {
508+
setAllowStateDispatch(true);
509+
}
510+
511+
public int getAndIncrement() {
512+
int before = this.state.getValue();
513+
dispatch("increment"); // dispatches to MyState.increment() via state dispatch
514+
return before;
515+
}
516+
517+
@Override
518+
protected Class<MyState> getStateType() {
519+
return MyState.class;
520+
}
521+
}
522+
523+
@Test
524+
void dispatch_basicReentrantCall() throws Exception {
525+
BonusDepositEntity entity = new BonusDepositEntity();
526+
TaskEntityOperation op = createOperation("depositWithBonus", 100);
527+
entity.runAsync(op);
528+
529+
// 100 + 10% bonus = 110
530+
assertEquals(110, entity.state);
531+
}
532+
533+
@Test
534+
void dispatch_withTypedReturnValue() throws Exception {
535+
ComputeEntity entity = new ComputeEntity();
536+
TaskEntityOperation op = createOperation("quadruple", 5);
537+
Object result = entity.runAsync(op);
538+
539+
// 5 * 2 = 10, then 10 * 2 = 20
540+
assertEquals(20, result);
541+
}
542+
543+
@Test
544+
void dispatch_implicitDelete() throws Exception {
545+
SelfDeletingEntity entity = new SelfDeletingEntity();
546+
DataConverter converter = new JacksonDataConverter();
547+
String serializedState = converter.serialize("existing");
548+
549+
TaskEntityOperation op = createOperation("resetAndDelete", null, serializedState);
550+
Object result = entity.runAsync(op);
551+
552+
assertEquals("deleted", result);
553+
assertNull(entity.state);
554+
}
555+
556+
@Test
557+
void dispatch_caseInsensitive() throws Exception {
558+
BonusDepositEntity entity = new BonusDepositEntity();
559+
// "deposit" is the method, but dispatch uses "DEPOSIT" internally — let's validate
560+
// by calling depositWithBonus which dispatches "deposit"
561+
TaskEntityOperation op = createOperation("DEPOSITWITHBONUS", 100);
562+
entity.runAsync(op);
563+
assertEquals(110, entity.state);
564+
}
565+
566+
@Test
567+
void dispatch_toStateDispatchedMethod() throws Exception {
568+
StateDispatchWithReentrancy entity = new StateDispatchWithReentrancy();
569+
DataConverter converter = new JacksonDataConverter();
570+
String serializedState = converter.serialize(new MyState());
571+
572+
TaskEntityOperation op = createOperation("getAndIncrement", null, serializedState);
573+
Object result = entity.runAsync(op);
574+
575+
// Before increment was 0
576+
assertEquals(0, result);
577+
// State should now be 1 after dispatch("increment")
578+
assertEquals(1, entity.state.getValue());
579+
}
580+
581+
@Test
582+
void dispatch_unknownOperation_throwsException() throws Exception {
583+
BonusDepositEntity entity = new BonusDepositEntity();
584+
// First trigger runAsync to set the context on the entity, then test dispatch failure
585+
// We use a custom entity that dispatches an unknown operation
586+
TaskEntity<Void> failEntity = new TaskEntity<Void>() {
587+
public void bad() {
588+
dispatch("nonExistent");
589+
}
590+
591+
@Override
592+
protected Class<Void> getStateType() {
593+
return null;
594+
}
595+
};
596+
597+
assertThrows(UnsupportedOperationException.class, () -> {
598+
failEntity.runAsync(createOperation("bad"));
599+
});
600+
}
601+
602+
@Test
603+
void dispatch_outsideExecution_throwsIllegalState() {
604+
BonusDepositEntity entity = new BonusDepositEntity();
605+
// dispatch() called without runAsync() first (no context set)
606+
assertThrows(IllegalStateException.class, () -> {
607+
entity.dispatch("deposit", 10);
608+
});
609+
}
610+
611+
@Test
612+
void dispatch_noInputOverload() throws Exception {
613+
CounterEntity entity = new CounterEntity();
614+
// Use a wrapper entity that dispatches "reset" with no input
615+
TaskEntity<Integer> resetDispatcher = new TaskEntity<Integer>() {
616+
public void doReset() {
617+
this.state = 42;
618+
dispatch("reset"); // reset sets state to 0
619+
}
620+
621+
public void reset() {
622+
this.state = 0;
623+
}
624+
625+
@Override
626+
protected Integer initializeState(TaskEntityOperation operation) {
627+
return 0;
628+
}
629+
630+
@Override
631+
protected Class<Integer> getStateType() {
632+
return Integer.class;
633+
}
634+
};
635+
636+
TaskEntityOperation op = createOperation("doReset");
637+
resetDispatcher.runAsync(op);
638+
assertEquals(0, resetDispatcher.state);
639+
}
640+
641+
// endregion
421642
}

0 commit comments

Comments
 (0)