Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

runtimeOnly 'org.postgresql:postgresql'
implementation 'org.postgresql:postgresql'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
41 changes: 33 additions & 8 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { getApiErrorCode, getApiErrorMessage, isAccessAuthError } from './api/apiError'
import { createResumeMeta } from './session/sessionRecovery.js'
import { resolveMutationGame } from './session/mutationReplay.js'
import { createAsyncRequestGate, progressRequestKey } from './session/asyncRequestGate.js'
import AccessScreen from './screens/AccessScreen'
import GamePlayScreen from './screens/GamePlayScreen'
import GameSetupScreen from './screens/GameSetupScreen'
Expand Down Expand Up @@ -52,6 +53,7 @@ function App() {
const [sessionsNextCursor, setSessionsNextCursor] = useState(null)
const [sessionsHasMore, setSessionsHasMore] = useState(false)
const sessionRequestRef = useRef(0)
const requestGateRef = useRef(createAsyncRequestGate())
const [resumingSessionId, setResumingSessionId] = useState(null)

const [setupFieldErrors, setSetupFieldErrors] = useState({})
Expand All @@ -70,6 +72,12 @@ function App() {
if (!isAccessAuthError(error)) return false
setAuthMessage(getApiErrorMessage(error))
setAuthMessageKind('api')
requestGateRef.current.invalidate()
sessionRequestRef.current += 1
setSessionsLoading(false)
setResumingSessionId(null)
setIsProgressing(false)
setIsStarting(false)
setAuthState('login')
setGameData(null)
setResumeMeta(null)
Expand All @@ -79,12 +87,13 @@ function App() {
const loadSessions = async (append = false) => {
if (append && (!sessionsHasMore || sessionsLoading)) return
const request = ++sessionRequestRef.current
const epoch = requestGateRef.current.epoch()
const cursor = append ? sessionsNextCursor : null
setSessionsLoading(true)
setSessionsError('')
try {
const data = await listGameSessions(cursor)
if (request !== sessionRequestRef.current) return
if (request !== sessionRequestRef.current || !requestGateRef.current.isCurrent(epoch)) return
setSessions((current) => append
? [...current, ...data.items.filter((item) => !current.some((existing) => existing.sessionId === item.sessionId))]
: data.items)
Expand All @@ -96,7 +105,7 @@ function App() {
setSessionsError(getApiErrorMessage(error, '저장된 이야기를 불러오지 못했습니다.'))
}
} finally {
if (request === sessionRequestRef.current) setSessionsLoading(false)
if (request === sessionRequestRef.current && requestGateRef.current.isCurrent(epoch)) setSessionsLoading(false)
}
}

Expand Down Expand Up @@ -174,19 +183,22 @@ function App() {

const idempotencyKey = initIdempotencyKeyRef.current || createIdempotencyKey()
initIdempotencyKeyRef.current = idempotencyKey
const epoch = requestGateRef.current.epoch()
setIsStarting(true)
setSetupError('')

try {
const data = await initGame(world, character, idempotencyKey)
const resolved = await resolveMutationGame(data, resumeGameSession)
if (!requestGateRef.current.isCurrent(epoch)) return
initIdempotencyKeyRef.current = null
setGameData(resolved.game)
setResumeMeta(resolved.meta)
setIsTypingComplete(false)
setProgressError(null)
setPendingChoiceId(null)
} catch (error) {
if (!requestGateRef.current.isCurrent(epoch)) return
if (!requireReauthentication(error)) {
console.error(error)
setSetupError(getApiErrorMessage(
Expand All @@ -195,16 +207,18 @@ function App() {
))
}
} finally {
setIsStarting(false)
if (requestGateRef.current.isCurrent(epoch)) setIsStarting(false)
}
}

const handleResume = async (targetSessionId) => {
if (resumingSessionId != null) return
const epoch = requestGateRef.current.begin('resume')
if (epoch == null) return
setResumingSessionId(targetSessionId)
setSessionsError('')
try {
const resumed = await resumeGameSession(targetSessionId)
if (!requestGateRef.current.isCurrent(epoch)) return
if (!resumed.game) {
setSessionsError(resumed.statusMessage || '이 세션은 안전하게 재개할 수 없습니다.')
await loadSessions()
Expand All @@ -216,11 +230,13 @@ function App() {
setProgressError(null)
setPendingChoiceId(null)
} catch (error) {
if (!requestGateRef.current.isCurrent(epoch)) return
if (!requireReauthentication(error)) {
setSessionsError(getApiErrorMessage(error, '세션을 재개하지 못했습니다.'))
}
} finally {
setResumingSessionId(null)
requestGateRef.current.end('resume', epoch)
if (requestGateRef.current.isCurrent(epoch)) setResumingSessionId(null)
}
}

Expand All @@ -230,26 +246,31 @@ function App() {
const choice = gameData?.choices?.find((candidate) => candidate.id === choiceId)
if (!choice) return

const idempotencyKey = retryIdempotencyKey || createIdempotencyKey()
const epoch = requestGateRef.current.begin('progress')
if (epoch == null) return
const idempotencyKey = progressRequestKey(retryIdempotencyKey, createIdempotencyKey)
setIsProgressing(true)
setPendingChoiceId(choiceId)
setProgressError(null)

try {
const nextData = await progressGame(sessionId, choice, turnNumber, idempotencyKey)
const resolved = await resolveMutationGame(nextData, resumeGameSession)
if (!requestGateRef.current.isCurrent(epoch)) return
setGameData(resolved.game)
setResumeMeta(resolved.meta)
setIsTypingComplete(false)
setProgressError(null)
} catch (error) {
if (!requestGateRef.current.isCurrent(epoch)) return
if (!requireReauthentication(error)) {
console.error(error)
const errorCode = getApiErrorCode(error)
let resumedMeta = null
if (isRetryableProgressError(error) || errorCode === 'NARRATIVE_PROVIDER_RECOVERY_WAIT') {
try {
const resumed = await resumeGameSession(sessionId)
if (!requestGateRef.current.isCurrent(epoch)) return
if (resumed?.game) {
resumedMeta = createResumeMeta(resumed)
setResumeMeta(resumedMeta)
Expand All @@ -266,6 +287,7 @@ function App() {
}
}
} catch (resumeError) {
if (!requestGateRef.current.isCurrent(epoch)) return
if (!requireReauthentication(resumeError)) {
console.error(resumeError)
}
Expand All @@ -283,8 +305,11 @@ function App() {
})
}
} finally {
setPendingChoiceId(null)
setIsProgressing(false)
requestGateRef.current.end('progress', epoch)
if (requestGateRef.current.isCurrent(epoch)) {
setPendingChoiceId(null)
setIsProgressing(false)
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions frontend/src/session/asyncRequestGate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Authentication changes invalidate every outstanding session / mutation response.
export function createAsyncRequestGate() {
let generation = 0
const active = new Set()

return {
epoch: () => generation,
isCurrent: (epoch) => generation === epoch,
begin: (operation) => {
if (active.has(operation)) return null
active.add(operation)
return generation
},
end: (operation, epoch) => {
if (generation === epoch) active.delete(operation)
},
invalidate: () => {
generation += 1
active.clear()
},
}
}

export function progressRequestKey(retryKey, createKey) {
return retryKey || createKey()
}
43 changes: 43 additions & 0 deletions frontend/src/session/asyncRequestGate.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createAsyncRequestGate, progressRequestKey } from './asyncRequestGate.js'

test('duplicate progress uses one active operation even before React state commits', () => {
const gate = createAsyncRequestGate()
const first = gate.begin('progress')
assert.equal(first, 0)
assert.equal(gate.begin('progress'), null)
gate.end('progress', first)
assert.equal(gate.begin('progress'), first)
})

test('duplicate resume requests are suppressed until the first completes', () => {
const gate = createAsyncRequestGate()
const first = gate.begin('resume')
assert.equal(gate.begin('resume'), null)
gate.end('resume', first)
assert.equal(gate.begin('resume'), first)
})

test('reauthentication invalidates stale async responses and releases locks', () => {
const gate = createAsyncRequestGate()
const stale = gate.begin('progress')
gate.invalidate()
const current = gate.begin('progress')
assert.equal(gate.isCurrent(stale), false)
assert.equal(gate.isCurrent(current), true)
gate.end('progress', stale)
assert.equal(gate.begin('progress'), null)
gate.end('progress', current)
assert.equal(gate.begin('progress'), current)
})

test('retry sends the identical idempotency key while a new choice gets a new key', () => {
let generated = 0
const create = () => 'key-' + ++generated
const first = progressRequestKey(null, create)
const retry = progressRequestKey(first, create)
assert.equal(retry, first)
assert.equal(generated, 1)
assert.equal(progressRequestKey(null, create), 'key-2')
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.databind.ObjectMapper;
import org.postgresql.util.PSQLException;

@Service
public class GamePersistenceService {
Expand Down Expand Up @@ -45,70 +45,19 @@ public GamePersistenceService(GameSessionRepository gameSessionRepository, GameL
GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec,
CombatAuditCodec combatAuditCodec, QuestAuditCodec questAuditCodec, RelationshipAuditCodec relationshipAuditCodec,
StoryMemoryAuditCodec storyMemoryAuditCodec) {
this.gameSessionRepository = gameSessionRepository;
this.gameLogRepository = gameLogRepository;
this.gameStateSnapshotRepository = gameStateSnapshotRepository;
this.imageAssetRepository = imageAssetRepository;
this.gameMutationRequestRepository = gameMutationRequestRepository;
this.gameStateCodec = gameStateCodec;
this.gameStateRecovery = gameStateRecovery;
this.inventoryAuditCodec = inventoryAuditCodec;
this.vitalsAuditCodec = vitalsAuditCodec;
this.combatAuditCodec = combatAuditCodec;
this.questAuditCodec = questAuditCodec;
this.relationshipAuditCodec = relationshipAuditCodec;
this.storyMemoryAuditCodec = storyMemoryAuditCodec;
}

public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository,
GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository,
GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec,
GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec,
CombatAuditCodec combatAuditCodec, QuestAuditCodec questAuditCodec) {
this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository,
gameMutationRequestRepository, gameStateCodec, gameStateRecovery, inventoryAuditCodec, vitalsAuditCodec,
combatAuditCodec, questAuditCodec, new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper()));
}

public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository,
GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository,
GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec,
GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec,
CombatAuditCodec combatAuditCodec) {
this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository,
gameMutationRequestRepository, gameStateCodec, gameStateRecovery, inventoryAuditCodec, vitalsAuditCodec,
combatAuditCodec, new QuestAuditCodec(new ObjectMapper()), new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper()));
}

public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository,
GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository,
GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec,
GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec) {
this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository,
gameMutationRequestRepository, gameStateCodec, gameStateRecovery, inventoryAuditCodec, vitalsAuditCodec,
new CombatAuditCodec(new ObjectMapper()), new QuestAuditCodec(new ObjectMapper()),
new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper()));
}

public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository,
GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository,
GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec,
GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec) {
this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository,
gameMutationRequestRepository, gameStateCodec, gameStateRecovery, inventoryAuditCodec,
new VitalsAuditCodec(new ObjectMapper()), new CombatAuditCodec(new ObjectMapper()),
new QuestAuditCodec(new ObjectMapper()), new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper()));
}

public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository,
GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository,
GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec,
GameStateRecovery gameStateRecovery) {
this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository,
gameMutationRequestRepository, gameStateCodec, gameStateRecovery,
new InventoryAuditCodec(new ObjectMapper()), new VitalsAuditCodec(new ObjectMapper()),
new CombatAuditCodec(new ObjectMapper()), new QuestAuditCodec(new ObjectMapper()),
new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper()));
this.gameSessionRepository = java.util.Objects.requireNonNull(gameSessionRepository, "gameSessionRepository");
this.gameLogRepository = java.util.Objects.requireNonNull(gameLogRepository, "gameLogRepository");
this.gameStateSnapshotRepository = java.util.Objects.requireNonNull(gameStateSnapshotRepository, "gameStateSnapshotRepository");
this.imageAssetRepository = java.util.Objects.requireNonNull(imageAssetRepository, "imageAssetRepository");
this.gameMutationRequestRepository = java.util.Objects.requireNonNull(gameMutationRequestRepository, "gameMutationRequestRepository");
this.gameStateCodec = java.util.Objects.requireNonNull(gameStateCodec, "gameStateCodec");
this.gameStateRecovery = java.util.Objects.requireNonNull(gameStateRecovery, "gameStateRecovery");
this.inventoryAuditCodec = java.util.Objects.requireNonNull(inventoryAuditCodec, "inventoryAuditCodec");
this.vitalsAuditCodec = java.util.Objects.requireNonNull(vitalsAuditCodec, "vitalsAuditCodec");
this.combatAuditCodec = java.util.Objects.requireNonNull(combatAuditCodec, "combatAuditCodec");
this.questAuditCodec = java.util.Objects.requireNonNull(questAuditCodec, "questAuditCodec");
this.relationshipAuditCodec = java.util.Objects.requireNonNull(relationshipAuditCodec, "relationshipAuditCodec");
this.storyMemoryAuditCodec = java.util.Objects.requireNonNull(storyMemoryAuditCodec, "storyMemoryAuditCodec");
}

@Transactional
Expand Down Expand Up @@ -245,11 +194,26 @@ private GameSession findOwnedSession(String ownerKey, Long sessionId) {
}

private boolean isTurnUniqueConstraintViolation(DataIntegrityViolationException exception) {
Throwable cause = exception.getMostSpecificCause();
String message = cause == null ? exception.getMessage() : cause.getMessage();
if (message == null) return false;
String normalized = message.toLowerCase();
return normalized.contains(GAME_LOG_TURN_UNIQUE_CONSTRAINT) || normalized.contains(IMAGE_ASSET_TURN_UNIQUE_CONSTRAINT);
for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
if (cause instanceof PSQLException postgres
&& "23505".equals(postgres.getSQLState())
&& postgres.getServerErrorMessage() != null
&& isTurnConstraint(postgres.getServerErrorMessage().getConstraint())) {
return true;
}
if (cause instanceof org.hibernate.exception.ConstraintViolationException hibernate
&& hibernate.getSQLException() != null
&& "23505".equals(hibernate.getSQLException().getSQLState())
&& isTurnConstraint(hibernate.getConstraintName())) {
return true;
}
}
return false;
}

private boolean isTurnConstraint(String constraintName) {
return GAME_LOG_TURN_UNIQUE_CONSTRAINT.equals(constraintName)
|| IMAGE_ASSET_TURN_UNIQUE_CONSTRAINT.equals(constraintName);
}

private GameState loadOrRecoverState(GameSession session) {
Expand Down
Loading
Loading