Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a23e4ca08 | ||
|
|
8c877b4a6f |
No files matched your search
@@ -1079,6 +1079,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
profileName?: string;
|
profileName?: string;
|
||||||
reservedTurns?: InMemoryReservedTurnStore;
|
reservedTurns?: InMemoryReservedTurnStore;
|
||||||
turnDaemonLease?: DatabaseTurnDaemonLease;
|
turnDaemonLease?: DatabaseTurnDaemonLease;
|
||||||
|
transactionMaxWaitMs?: number;
|
||||||
transactionTimeoutMs?: number;
|
transactionTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
): Promise<DatabaseTurnHooks> => {
|
): Promise<DatabaseTurnHooks> => {
|
||||||
@@ -1089,7 +1090,13 @@ export const createDatabaseTurnHooks = async (
|
|||||||
// Prisma's 5-second default matches the normal turn execution budget too
|
// Prisma's 5-second default matches the normal turn execution budget too
|
||||||
// closely. A populated season can finish the turn but expire while flushing
|
// closely. A populated season can finish the turn but expire while flushing
|
||||||
// it, which rolls the transaction back and marks the profile PAUSED.
|
// it, which rolls the transaction back and marks the profile PAUSED.
|
||||||
const transactionOptions = { timeout: options?.transactionTimeoutMs ?? 30_000 };
|
// 실행 timeout과 연결 획득 대기는 별도 제한이다. 기본 maxWait 2초는
|
||||||
|
// 일시적인 DB 지연에도 callback 시작 전에 턴을 영구 PAUSED로 만들었다.
|
||||||
|
// 대기를 유한하게 늘리되 callback 재실행이나 lease/fencing 우회는 하지 않는다.
|
||||||
|
const transactionOptions = {
|
||||||
|
maxWait: options?.transactionMaxWaitMs ?? 10_000,
|
||||||
|
timeout: options?.transactionTimeoutMs ?? 30_000,
|
||||||
|
};
|
||||||
const readModelBaseline = createRealtimeReadModelBaseline(world);
|
const readModelBaseline = createRealtimeReadModelBaseline(world);
|
||||||
let worldReadModelBaseline = createWorldReadModelSignature(world);
|
let worldReadModelBaseline = createWorldReadModelSignature(world);
|
||||||
let persistedTickSeconds = world.getState().tickSeconds;
|
let persistedTickSeconds = world.getState().tickSeconds;
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export interface TurnDaemonRuntimeOptions {
|
|||||||
* loaded once and no concurrent API writer may touch this database.
|
* loaded once and no concurrent API writer may touch this database.
|
||||||
*/
|
*/
|
||||||
exclusiveFastForward?: boolean;
|
exclusiveFastForward?: boolean;
|
||||||
|
databaseTransactionMaxWaitMs?: number;
|
||||||
databaseTransactionTimeoutMs?: number;
|
databaseTransactionTimeoutMs?: number;
|
||||||
onActionResolved?: NonNullable<Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']>;
|
onActionResolved?: NonNullable<Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']>;
|
||||||
}
|
}
|
||||||
@@ -933,6 +934,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
profileName: options.profileName ?? options.profile,
|
profileName: options.profileName ?? options.profile,
|
||||||
reservedTurns: reservedTurnStoreHandle?.store,
|
reservedTurns: reservedTurnStoreHandle?.store,
|
||||||
turnDaemonLease: turnDaemonLease ?? undefined,
|
turnDaemonLease: turnDaemonLease ?? undefined,
|
||||||
|
transactionMaxWaitMs: options.databaseTransactionMaxWaitMs,
|
||||||
transactionTimeoutMs: options.databaseTransactionTimeoutMs,
|
transactionTimeoutMs: options.databaseTransactionTimeoutMs,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -135,3 +135,106 @@ integration('turn failure personal-record persistence', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
integration('turn persistence connection acquisition', () => {
|
||||||
|
let connector: ReturnType<typeof createGamePostgresConnector>;
|
||||||
|
let hooks: DatabaseTurnHooks;
|
||||||
|
const fixtureWorldId = worldId + 10;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
await connector.prisma.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: fixtureWorldId,
|
||||||
|
scenarioCode: 'transaction-acquisition',
|
||||||
|
currentYear: state.currentYear,
|
||||||
|
currentMonth: state.currentMonth,
|
||||||
|
tickSeconds: state.tickSeconds,
|
||||||
|
clockBaseTime: turnTime,
|
||||||
|
clockWallAnchor: turnTime,
|
||||||
|
clockMode: 'manual',
|
||||||
|
clockPhase: 'MANUAL',
|
||||||
|
clockTick: 0n,
|
||||||
|
lastTurnTick: 0n,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hooks = await createDatabaseTurnHooks(
|
||||||
|
databaseUrl!,
|
||||||
|
new InMemoryTurnWorld({ ...state, id: fixtureWorldId }, snapshot, { schedule }),
|
||||||
|
{ transactionTimeoutMs: 1_000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await hooks?.close();
|
||||||
|
await connector?.prisma.worldState.deleteMany({ where: { id: fixtureWorldId } });
|
||||||
|
await connector?.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const occupyPool = async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const started: Promise<void>[] = [];
|
||||||
|
const blockers = Array.from({ length: connector.getPoolStats().max }, () => {
|
||||||
|
let markStarted!: () => void;
|
||||||
|
started.push(new Promise<void>((resolve) => (markStarted = resolve)));
|
||||||
|
return connector.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT 1`;
|
||||||
|
markStarted();
|
||||||
|
await held;
|
||||||
|
},
|
||||||
|
{ maxWait: 10_000, timeout: 20_000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Attach handlers immediately so a setup failure cannot leak transactions.
|
||||||
|
const settled = Promise.allSettled(blockers);
|
||||||
|
try {
|
||||||
|
await Promise.all(started);
|
||||||
|
} catch (error) {
|
||||||
|
release();
|
||||||
|
await settled;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { release, settled };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('waits beyond Prisma default without spending the callback execution budget', async () => {
|
||||||
|
const held = await occupyPool();
|
||||||
|
const sync = hooks.synchronizeClockAuthority();
|
||||||
|
// Observe the result immediately; the old 2-second default rejects here.
|
||||||
|
const outcome = sync.then(
|
||||||
|
(value) => ({ ok: true, value }),
|
||||||
|
(error: unknown) => ({ ok: false, error })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const deadline = Date.now() + 1_000;
|
||||||
|
while (!connector.getPoolStats().waiting && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
expect(connector.getPoolStats().waiting).toBeGreaterThan(0);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_500));
|
||||||
|
held.release();
|
||||||
|
expect(await outcome).toMatchObject({ ok: true });
|
||||||
|
} finally {
|
||||||
|
held.release();
|
||||||
|
await held.settled;
|
||||||
|
await outcome;
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it('still rejects sustained connection exhaustion at the finite acquisition limit', async () => {
|
||||||
|
const held = await occupyPool();
|
||||||
|
try {
|
||||||
|
await expect(hooks.synchronizeClockAuthority()).rejects.toMatchObject({ code: 'P2028' });
|
||||||
|
} finally {
|
||||||
|
held.release();
|
||||||
|
await held.settled;
|
||||||
|
}
|
||||||
|
// Timed-out acquisition must not prevent the next normal operation.
|
||||||
|
await expect(hooks.synchronizeClockAuthority()).resolves.toBeTypeOf('boolean');
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
@@ -710,6 +710,106 @@ test('trait and injury explanations appear on pointer hover and keyboard focus',
|
|||||||
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const width of [1200, 500]) {
|
||||||
|
test(`catalog description line breaks stay safe on hover and focus at ${width}px`, async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const warInfo =
|
||||||
|
'[계략] 화계·탈취·파괴·선동 : 성공률 +10%p<br>[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p';
|
||||||
|
const literalMarkup = '<img src=x onerror="alert(1)"><b>설명</b>';
|
||||||
|
const directoryRows = generals.map((general) =>
|
||||||
|
general.id === 10
|
||||||
|
? {
|
||||||
|
...general,
|
||||||
|
specialDomestic: {
|
||||||
|
...general.specialDomestic,
|
||||||
|
info: `상업 특기<br/>수익 증가<BR />매매 효율\r\n${literalMarkup}`,
|
||||||
|
},
|
||||||
|
specialWar: { key: 'che_신산', name: '신산', info: warInfo },
|
||||||
|
}
|
||||||
|
: general
|
||||||
|
);
|
||||||
|
await install(page, 'general', [], [], directoryRows);
|
||||||
|
await page.goto('general-list');
|
||||||
|
await page.reload();
|
||||||
|
const prefix = width <= 600 ? 'card-' : '';
|
||||||
|
const container = page
|
||||||
|
.locator(width <= 600 ? '[data-general-card-id="10"]' : 'tr[data-general-id="10"]')
|
||||||
|
.first();
|
||||||
|
await expect(container).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '갱 신' })).not.toHaveAttribute('aria-busy');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await document.fonts.ready;
|
||||||
|
await Promise.all(Array.from(document.images, (image) => image.decode().catch(() => undefined)));
|
||||||
|
});
|
||||||
|
const before = await container.boundingBox();
|
||||||
|
const evidence: Record<string, unknown> = { width, url: page.url(), before };
|
||||||
|
for (const [kind, expected] of [
|
||||||
|
['special-domestic', ['상업 특기', '수익 증가', '매매 효율', literalMarkup]],
|
||||||
|
['special-war', warInfo.split('<br>')],
|
||||||
|
] as const) {
|
||||||
|
const trigger = page.locator(`[data-directory-tooltip="${prefix}${kind}-10"]`);
|
||||||
|
const tooltip = trigger.getByRole('tooltip');
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await trigger.hover();
|
||||||
|
await expect(tooltip).toBeVisible();
|
||||||
|
await trigger.focus();
|
||||||
|
await expect(tooltip).toBeVisible();
|
||||||
|
await expect(trigger).toHaveCSS('text-decoration-line', 'none');
|
||||||
|
await expect(trigger).toHaveCSS('outline-style', 'solid');
|
||||||
|
const description = tooltip.locator('.directory-tooltip__description');
|
||||||
|
await expect(description.locator('br')).toHaveCount(expected.length - 1);
|
||||||
|
await expect(description.locator('img, b, script')).toHaveCount(0);
|
||||||
|
expect(await description.innerText()).not.toMatch(/<br\s*\/?\s*>/iu);
|
||||||
|
const metrics = await description.evaluate((element) => {
|
||||||
|
const lines = Array.from(element.childNodes).filter(
|
||||||
|
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
lines: lines.map((node) => {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(node);
|
||||||
|
return { text: node.textContent?.trim(), top: range.getBoundingClientRect().top };
|
||||||
|
}),
|
||||||
|
html: element.outerHTML,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(metrics.lines.map((line) => line.text)).toEqual(expected);
|
||||||
|
for (let index = 1; index < metrics.lines.length; index += 1) {
|
||||||
|
expect(metrics.lines[index]!.top).toBeGreaterThan(metrics.lines[index - 1]!.top);
|
||||||
|
}
|
||||||
|
const geometry = await tooltip.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
font: style.font,
|
||||||
|
background: style.backgroundColor,
|
||||||
|
position: style.position,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(geometry.right).toBeLessThanOrEqual(width - 8);
|
||||||
|
expect(geometry.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.bottom).toBeLessThanOrEqual(900);
|
||||||
|
expect(await container.boundingBox()).toEqual(before);
|
||||||
|
evidence[kind] = { ...metrics, geometry };
|
||||||
|
await page.screenshot({ path: testInfo.outputPath(`catalog-${kind}-${width}.png`), fullPage: true });
|
||||||
|
await trigger.blur();
|
||||||
|
}
|
||||||
|
const empty = page.locator(`[data-directory-tooltip="${prefix}special-war-20"]`);
|
||||||
|
await expect(empty).not.toHaveAttribute('tabindex');
|
||||||
|
await expect(empty.getByRole('tooltip')).toHaveCount(0);
|
||||||
|
await writeFile(testInfo.outputPath('catalog-description.json'), JSON.stringify(evidence, null, 2));
|
||||||
|
await writeFile(
|
||||||
|
testInfo.outputPath('catalog-description.html'),
|
||||||
|
await page.locator('main').evaluate((element) => element.outerHTML)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
|
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
await page.goto('npc-list');
|
await page.goto('npc-list');
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { computed } from 'vue';
|
||||||
|
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const descriptionLines = computed(() => splitLegacyLineBreaks(props.description ?? ''));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -16,7 +21,10 @@ defineProps<{
|
|||||||
<slot />
|
<slot />
|
||||||
<span v-if="description" class="directory-tooltip__content" role="tooltip">
|
<span v-if="description" class="directory-tooltip__content" role="tooltip">
|
||||||
<strong>{{ title }}</strong>
|
<strong>{{ title }}</strong>
|
||||||
<span>{{ description }}</span>
|
<span class="directory-tooltip__description"
|
||||||
|
><template v-for="(line, index) in descriptionLines" :key="index"
|
||||||
|
>{{ line }}<br v-if="index < descriptionLines.length - 1" /></template
|
||||||
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
|
||||||
|
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
|
||||||
import tippy, { type Instance, type Placement } from 'tippy.js';
|
import tippy, { type Instance, type Placement } from 'tippy.js';
|
||||||
import 'tippy.js/dist/tippy.css';
|
import 'tippy.js/dist/tippy.css';
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ let instance: Instance | null = null;
|
|||||||
const descriptionLines = computed(() => {
|
const descriptionLines = computed(() => {
|
||||||
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
|
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
|
||||||
return source
|
return source
|
||||||
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu))
|
.flatMap(splitLegacyLineBreaks)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* 카탈로그 설명의 Ref 줄바꿈만 해석한다. 반환값은 반드시 text interpolation으로
|
||||||
|
* 렌더링하며 HTML로 삽입하지 않는다. 빈 줄과 공백의 표시 정책은 소비자가 정한다.
|
||||||
|
*/
|
||||||
|
export const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r\n?|\n/giu);
|
||||||
@@ -4,6 +4,7 @@ import { usePageExit } from '../composables/usePageExit';
|
|||||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { splitLegacyLineBreaks } from '../utils/legacyDescription';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
|
|
||||||
@@ -207,7 +208,6 @@ const specialNameMap = computed(() => {
|
|||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r?\n/giu);
|
|
||||||
const selectedSpecialWarInfo = computed(
|
const selectedSpecialWarInfo = computed(
|
||||||
() => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? ''
|
() => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? ''
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { splitLegacyLineBreaks } from '../src/utils/legacyDescription.ts';
|
||||||
|
|
||||||
|
void describe('legacy catalog description line breaks', () => {
|
||||||
|
void it('accepts catalog br variants and platform newlines', () => {
|
||||||
|
assert.deepEqual(splitLegacyLineBreaks('가<br>나<BR/>다<br />라\r\n마\n바\r사'), [
|
||||||
|
'가',
|
||||||
|
'나',
|
||||||
|
'다',
|
||||||
|
'라',
|
||||||
|
'마',
|
||||||
|
'바',
|
||||||
|
'사',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('preserves empty lines, whitespace, and unformatted descriptions', () => {
|
||||||
|
assert.deepEqual(splitLegacyLineBreaks(''), ['']);
|
||||||
|
assert.deepEqual(splitLegacyLineBreaks(' 일반 설명 '), [' 일반 설명 ']);
|
||||||
|
assert.deepEqual(splitLegacyLineBreaks('<br>첫 줄<br><br>끝 줄\n'), ['', '첫 줄', '', '끝 줄', '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('leaves other markup, attributes, and entities as literal text', () => {
|
||||||
|
const text = '<img src=x onerror="alert(1)"><b>문구</b><br><br onclick="alert(1)">';
|
||||||
|
assert.deepEqual(splitLegacyLineBreaks(text), [text]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,14 @@ tooltip trigger는 hover/focus 동작과 `cursor: help`를 유지하되, tooltip
|
|||||||
텍스트에 점선 밑줄을 반복하지 않습니다. keyboard `focus-visible` outline은 별도의
|
텍스트에 점선 밑줄을 반복하지 않습니다. keyboard `focus-visible` outline은 별도의
|
||||||
접근성 상태로 유지합니다.
|
접근성 상태로 유지합니다.
|
||||||
|
|
||||||
|
카탈로그 설명의 줄바꿈은 `src/utils/legacyDescription.ts`의
|
||||||
|
`splitLegacyLineBreaks()`가 해석합니다. `DirectoryTooltip`, `RichTooltip`, 유산의
|
||||||
|
특기·유니크 설명은 `<br>`, `<br/>`, `<br />`(대소문자 무관)와 실제 개행을 이 함수로
|
||||||
|
나누고 각 줄을 Vue text interpolation으로 렌더링합니다. 다른 HTML이나 entity는
|
||||||
|
텍스트로 유지합니다. 일람·유산은 빈 줄을 보존하며, `RichTooltip`은 기존대로 각 줄의
|
||||||
|
앞뒤 공백과 빈 줄을 제거합니다. 일람의 표와 모바일 카드에는 같은 tooltip을 쓰며
|
||||||
|
줄바꿈 때문에 trigger의 geometry나 많은 행을 위한 lazy 구조를 바꾸지 않습니다.
|
||||||
|
|
||||||
## Button composition
|
## Button composition
|
||||||
|
|
||||||
Choose the Ref visual family before choosing a semantic color. Buttons from
|
Choose the Ref visual family before choosing a semantic color. Buttons from
|
||||||
|
|||||||
@@ -363,7 +363,13 @@ Hyper-V의 VM 정지/재개, 체크포인트 또는 host 시간 보정은 게스
|
|||||||
NTP 로그와 Windows Hyper-V/백업 작업 이력을 대조합니다. lease 기간을 늘리거나
|
NTP 로그와 Windows Hyper-V/백업 작업 이력을 대조합니다. lease 기간을 늘리거나
|
||||||
시간 동기화를 임의로 끄는 것으로 해결하지 않습니다.
|
시간 동기화를 임의로 끄는 것으로 해결하지 않습니다.
|
||||||
|
|
||||||
Turn daemon의 DB persistence interactive transaction은 기본 30초입니다. 정상 turn
|
Turn daemon의 DB persistence interactive transaction은 연결 획득 대기(`maxWait`)
|
||||||
|
10초와 callback 실행(`timeout`) 30초를 각각 제한합니다. Prisma 기본 연결 대기
|
||||||
|
2초는 일시적인 DB 지연에도 callback 시작 전 `P2028`로 서버를 PAUSED로 만들 수
|
||||||
|
있습니다. 대기 상한을 넘거나 실행·lease 검증이 실패하면 기존 오류 처리를 유지하며,
|
||||||
|
callback을 자동 재실행하지 않습니다. 내부 runtime 설정의
|
||||||
|
`databaseTransactionMaxWaitMs`와 `databaseTransactionTimeoutMs`로 각각 조정합니다.
|
||||||
|
정상 turn
|
||||||
budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서
|
budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서
|
||||||
rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한
|
rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한
|
||||||
transaction의 계산·RNG·write 순서는 바뀌지 않지만 lock 보유 상한도 함께 늘어나므로
|
transaction의 계산·RNG·write 순서는 바뀌지 않지만 lock 보유 상한도 함께 늘어나므로
|
||||||
@@ -751,7 +757,6 @@ NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를
|
|||||||
release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은
|
release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은
|
||||||
공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다.
|
공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다.
|
||||||
|
|
||||||
|
|
||||||
### 이미지 업로드와 WebPush 전송 경계
|
### 이미지 업로드와 WebPush 전송 경계
|
||||||
|
|
||||||
게시판 이미지의 기존 1 MiB 입력·2048px 변환 정책, AVIF 원본 보존과 WebP 변환,
|
게시판 이미지의 기존 1 MiB 입력·2048px 변환 정책, AVIF 원본 보존과 WebP 변환,
|
||||||
|
|||||||
Reference in new issue
Block a user