콘텐츠로 이동

8. Session과 Actor binding

가이드 홈 | 이전: 7. Actor와 Spot | 다음: 9. STREAM

다른 언어로 보기 — C#/.NET · C++ · Java · Kotlin · Node/TypeScript

이 장의 계약 소유 문서Session Actor dispatch가 동작을, 언어별 STREAM session · bound session 공개 계약이 정확한 시그니처를 소유한다.

Session binding은 client STREAM session과 exact Actor incarnation을 연결한다. Binding 뒤 session은 client packet을 Actor로 relay할 수 있고, Actor는 같은 session으로 push할 수 있다.

Binding은 Actor의 Spot membership과 독립이다. Actor가 다른 Spot이나 node로 relocation되어도 ActorIdObjectGeneration은 유지되며 Framework가 binding route를 갱신한다.

개수는 한쪽만 열려 있다. Session 하나는 여러 Actor를 동시에 bind할 수 있다 — 한 connection이 player Actor와 party Actor를 함께 써도 된다. 반대로 Actor 하나는 동시에 session 하나에만 bind된다. 새 binding이 확정되면 이전 binding은 무효가 되고 그쪽으로 온 늦은 message는 거부된다.

relay는 Location Store를 다시 조회하지 않는다. bind할 때 확인한 route를 session이 Actor마다 보관하고 그것으로 보낸다. Actor가 옮겨가면 relocation commit 뒤 Framework가 그 보관 route를 갱신한다 — application이 다시 bind하지 않는다.

1. 인증 뒤 Actor bind

Session handler에서 Actor를 생성하거나 조회한 다음 ActorRef를 bind한다. Local Actor instance나 target NodeRid를 직접 전달하지 않는다.

샘플에서 보기 — TicTacToe. 인증 요청을 받아 player Actor를 만들고 session에 bind한 뒤 reply까지 보내는 자리다. 저장소의 실제 코드다.

public final class AuthenticatePlaySessionHandler
    implements ZLinkTypedSessionPacketHandler<ZLinkSessionContext, AuthenticateReq> {
    private static final Logger LOGGER = LoggerFactory.getLogger(
        AuthenticatePlaySessionHandler.class);

    private final ZLinkActorManager actors;
    private final ZLinkClient channels;

    public AuthenticatePlaySessionHandler(
        ZLinkActorManager actors,
        ZLinkClient channels) {
        this.actors = actors;
        this.channels = channels;
    }

    @Override
    public Class<AuthenticateReq> messageType() {
        return AuthenticateReq.class;
    }

    @Override
    public CompletionStage<Void> handle(
        ZLinkSessionContext context,
        ZLinkSessionDispatchContext dispatch,
        AuthenticateReq request) {
        if (request.accessToken() == null || request.accessToken().isBlank()) {
            throw new IllegalArgumentException("access token is required");
        }
        return channels
            .requestToChannel(
                SampleNames.ApiChannel,
                new AuthenticatePlayerReq(request.accessToken()))
            .timeout(SampleNames.RequestTimeout)
            .submit(AuthenticatePlayerRes.class)
            .thenCompose(authenticated -> actors.getOrCreate(
                    authenticated.player().actorId(), SampleNames.PlayActor)
                .request(new PlayerActorCreateReq(authenticated.player()))
                .submit()
                .thenCompose(result -> {
                    ActorRef resolvedActor = requireActor(result);
                    return context.actors().bind(requireActor(result))
                        .thenCompose(boundActor -> {
                            if (!boundActor.ref().equals(resolvedActor)) {
                                throw new IllegalStateException(
                                    "Bound ActorRef does not match the resolved ActorRef for '"
                                        + authenticated.player().actorId() + "'.");
                            }
                            if (result instanceof ZLinkActorCreateResult.Existing) {
                                LOGGER.info(
                                    "tictactoe-lifecycle actor-bound actor={}",
                                    boundActor.actorId());
                            }
                            return context.client()
                                .reply(new AuthenticateRes(authenticated.player()))
                                .submit();
                        });
                }));
    }

    private static ActorRef requireActor(ZLinkActorCreateResult result) {
        if (result instanceof ZLinkActorCreateResult.Existing existing) {
            return existing.actor();
        }
        if (result instanceof ZLinkActorCreateResult.Created created) {
            return created.actor();
        }
        throw new IllegalStateException("Play actor creation was rejected.");
    }
}

최소 형태로 보면 이렇다.

public CompletionStage<Void> handle(
    ZLinkSessionContext context, ZLinkSessionDispatchContext dispatch, Authenticate request) {
    return actors
        .getOrCreate(request.playerId(), "player")
        .request(new CreatePlayer(request.displayName()))
        .submit()
        // 같은 exact incarnation이 이미 bind됐으면 기존 route를 반환한다.
        .thenCompose(result -> context.actors().bind(requireActor(result)))
        // 현재 request의 one-shot reply를 제출한다.
        .thenRun(() -> context.client()
            .reply(new Authenticated(request.playerId()))
            .submit());
}

private static ActorRef requireActor(ZLinkActorCreateResult result) {
    if (result instanceof ZLinkActorCreateResult.Existing existing) return existing.actor();
    if (result instanceof ZLinkActorCreateResult.Created created) return created.actor();
    throw new IllegalStateException("Player creation was rejected.");
}

bind는 중복 bind를 오류로 처리한다. 인증 재전송처럼 이미 bind됐을 수 있는 흐름은 bindOrGet를 사용한다.

2. Session packet을 Actor로 relay

Session의 configure()에서 인증 같은 session 전용 handler를 등록한다. 처리하지 않은 packet은 bound Actor로 넘긴다.

public final class PlaySession implements ZLinkSession {
    private final ZLinkSessionContext context;

    @Override
    public void configure() {
        // Actor binding 전에 처리할 packet을 등록한다.
        context.handlers().addHandler(AuthenticateHandler.class);
    }

    @Override
    public CompletionStage<Void> onDispatch(
        ZLinkSessionDispatchContext dispatch, ZLinkMessage payload) {
        return context.handlers().tryHandle(context, dispatch, payload).thenCompose(handled -> {
            if (handled) return CompletableFuture.completedFuture(null);
            // decode하지 않고 Framework-owned payload를 Actor handler로 넘긴다.
            return requireSingleBoundActor().relay(payload).thenApply(ignored -> null);
        });
    }

    @Override
    public CompletionStage<Void> onConnected() { return CompletableFuture.completedFuture(null); }

    @Override
    public CompletionStage<Void> onDisconnected() { return CompletableFuture.completedFuture(null); }
}

한 session에 여러 Actor를 bind할 수 있다. 이 경우 application protocol이 선택한 ActorId를 Context.Actors.Find(actorId)에 전달한다. Framework는 임의의 Actor를 선택하지 않는다.

3. Disconnect 통지

Physical STREAM disconnect는 Framework가 current binding 전체에 자동으로 통지한다. 연결이 유지된 상태에서 logical disconnect를 알릴 때만 명시적으로 호출한다.

ZLinkSessionActor actor = context.actors().find(playerId);
if (actor != null) {
    // Actor가 속한 Spot의 onDisconnectActor callback 완료까지 기다린다.
    actor.notifyDisconnected().toCompletableFuture().join();
}

Disconnect는 Actor를 삭제하거나 Entry Spot으로 이동시키지 않는다. 재접속한 session은 같은 ActorRef를 다시 조회해 bind할 수 있다.

한 Actor의 통지가 실패해도 나머지는 계속한다. Framework는 연결이 끊긴 시점의 binding snapshot을 고정하고 각 Actor에 통지하는데, 그중 하나가 실패하거나 callback이 deadline을 넘겨도 남은 Actor 통지와 session cleanup을 멈추지 않는다.

자동 통지와 명시 호출이 겹쳐도 callback은 한 번만 실행된다. 같은 binding에 대한 두 통지를 Framework가 합치므로, 명시 호출 직후 연결이 끊겨도 Spot의 disconnect callback이 두 번 돌지 않는다.

4. Actor에서 client로 push

Actor handler는 Context.BoundSession으로 현재 bound client에 메시지를 보낸다.

public CompletionStage<Void> handle(
    GameRoom spot, PlayerActor actor, ZLinkMessageContext messageContext, StateChanged message) {
    // 현재 bound session의 local admission까지 기다린다.
    return actor.context().boundSession()
        .send(new GameStateNotify(message.state()))
        .metadata("revision", String.valueOf(message.revision()))
        .submit();
}

Bound session은 push와 disconnect만 제공한다. Client request에 대한 Actor reply는 request handler의 반환값으로 처리한다.

5. 오류를 처리하는 기준

상황 결과
Actor가 없거나 Ready가 아님 bind가 typed framework error로 끝난다.
ObjectGeneration이 다름 stale ActorRef를 다른 incarnation에 bind하지 않는다.
Actor relocation seal 진행 중 ActorMoving으로 끝나며 hidden retry하지 않는다.
Binding 뒤 Actor relocation Framework가 route를 갱신하며 session을 다시 bind하지 않는다.
Session disconnect Actor와 Spot membership은 유지한다.
Session이 닫힌 뒤 도착한 reply 버린다. 새 session이나 새 binding의 reply로 쓰지 않는다.
relay 뒤 timeout · route 실패 다른 Actor · 새 owner · 다른 node로 자동 재전송하지 않는다.

ActorRef.MeshNameNodeRid는 최초 control route의 snapshot이다. Application은 stale route를 새로 조합하지 말고 actor manager의 조회 호출로 current ref를 다시 얻는다.

6. 관련 문서