fix(assets): a busy shard is not a broken one, and a column that reached no existing install (Phase 9a)
Two defects the Phase 9 acceptance walk found on a real rig, one of them ours and
one of them released (docs/link/v8.md §17.14).
## "The shard is not answering for client files" about a shard that was fine
The status call exhausts its 425 backoff whenever something else holds the
shard's single asset slot -- an import the operator started, or the item-art warm
pass refilling itself after a client patch. Phase 8's panel rendered that with
the same banner as a shard that is down or has the plane switched off, and left
it standing, because the page only re-reads after an action. On the rig it was up
for a quarter of an hour while the warm pass refilled 313 pictures and every
direct call to the same route answered normally.
BUSY now says what it is, and one automatic re-read four seconds later clears the
ordinary case. One per mount, guarded by a ref: a page that retried forever would
be holding the slot it is waiting for. DOWN, DISABLED and NO_IMAGING read exactly
as they did.
## Every spawn-atlas import on an upgraded install has failed since v1.2.0
`shard_spawn_points.unique_id` (Events Phase 12b) was added to the CREATE TABLE
and nowhere else. `CREATE TABLE IF NOT EXISTS` does not add a column to a table
that already exists -- which is what the twenty-odd `ADD COLUMN IF NOT EXISTS`
lines in this same file are for -- so it reached fresh installs and no existing
one, and `replaceAtlas` inserts the column unconditionally:
Unknown column 'unique_id' in 'INSERT INTO'
No bestiary refresh, no spawn map, no champion altars, on every install whose
tables predate 12b. A fresh install cannot reproduce it and neither can a test
whose schema is this file applied to an empty database; it took a rig with old
tables. Org lead, weighing that it is already released: it ships here on edge
rather than as a hotfix to main.
Verified by dropping the column, rebooting, watching the replay put it back, and
importing 6,455 spawners over the bridge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -13,6 +13,7 @@ module that follows.
|
|||||||
│ module-uo (>>> HERE <<<) │
|
│ module-uo (>>> HERE <<<) │
|
||||||
│ shard status · spawn atlas · marketplace │
|
│ shard status · spawn atlas · marketplace │
|
||||||
│ governors · cliloc · town crier · uo-link│
|
│ governors · cliloc · town crier · uo-link│
|
||||||
|
│ client files: portraits, item art, names │
|
||||||
└───────────────────────────────────────────┘
|
└───────────────────────────────────────────┘
|
||||||
│ server half: routers, models, schema fragment
|
│ server half: routers, models, schema fragment
|
||||||
│ client half: prebuilt ESM chunk, SPA routes + nav
|
│ client half: prebuilt ESM chunk, SPA routes + nav
|
||||||
@@ -182,7 +183,7 @@ reaches the container.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `UOLINK_BASE_URL` | — | Default sidecar base URL for a site with nothing saved yet. The admin panel's stored value wins. |
|
| `UOLINK_BASE_URL` | — | Default sidecar base URL for a site with nothing saved yet. The admin panel's stored value wins. |
|
||||||
| `UOLINK_WS_URL` | — | Same, for the WebSocket URL. |
|
| `UOLINK_WS_URL` | — | Same, for the WebSocket URL. |
|
||||||
| `UOLINK_PROTOCOL` | `3` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
|
| `UOLINK_PROTOCOL` | `8` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
|
||||||
| `TOWNCRIER_DURATION_SEC` | `3600` | How long a published news post's in-game town-crier message stays up (≤ `86400`). |
|
| `TOWNCRIER_DURATION_SEC` | `3600` | How long a published news post's in-game town-crier message stays up (≤ `86400`). |
|
||||||
|
|
||||||
**The sidecar's auth token is deliberately not here.** It is entered in Admin → Shard, encrypted at
|
**The sidecar's auth token is deliberately not here.** It is entered in Admin → Shard, encrypted at
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import api from '../../api.js'
|
import api from '../../api.js'
|
||||||
import { ErrorState, Loading } from '../../core.js'
|
import { ErrorState, Loading } from '../../core.js'
|
||||||
import Row from '../../components/DetailRow.jsx'
|
import Row from '../../components/DetailRow.jsx'
|
||||||
@@ -245,6 +245,28 @@ export default function ClientFiles() {
|
|||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
// One automatic re-read when the shard answered BUSY (§3.2's single slot),
|
||||||
|
// and exactly one per mount.
|
||||||
|
//
|
||||||
|
// BUSY is not a fault and it is not sticky on the shard — it means something
|
||||||
|
// else held the asset slot for longer than the client's own 425 backoff, and
|
||||||
|
// the two things that hold it are both ordinary: an import the operator
|
||||||
|
// started, and the item-art warm pass refilling itself after a client patch.
|
||||||
|
// The panel does not poll, so without this the operator is left reading a
|
||||||
|
// refusal about a shard that was free again seconds later, until they think to
|
||||||
|
// reload. A second read clears the common case; if it is still busy, the
|
||||||
|
// sentence says to come back, because a page that retried forever would be
|
||||||
|
// holding the slot it is waiting for.
|
||||||
|
const retried = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (retried.current || busy) return
|
||||||
|
const stillBusy = assets?.code === 'BUSY' || clilocs?.code === 'BUSY'
|
||||||
|
if (!stillBusy) return
|
||||||
|
retried.current = true
|
||||||
|
const t = setTimeout(() => load({ quiet: true }), 4000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [assets, clilocs, busy, load])
|
||||||
|
|
||||||
// Every action shares this: run it, say what it said, then re-read status so
|
// Every action shares this: run it, say what it said, then re-read status so
|
||||||
// the panel reflects the world rather than what we assumed happened.
|
// the panel reflects the world rather than what we assumed happened.
|
||||||
async function run(section, table, fn) {
|
async function run(section, table, fn) {
|
||||||
@@ -347,9 +369,22 @@ export default function ClientFiles() {
|
|||||||
style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}
|
style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}
|
||||||
className="sans"
|
className="sans"
|
||||||
>
|
>
|
||||||
<strong style={{ color: 'var(--head)' }}>The shard is not answering for client files.</strong>
|
{/* BUSY is the one code here that is not a fault, and saying "the shard
|
||||||
|
is not answering" about it sends an operator to check a shard that is
|
||||||
|
working. The slot is held by something ordinary — an import running,
|
||||||
|
or the warm pass — and it frees itself. */}
|
||||||
|
<strong style={{ color: 'var(--head)' }}>
|
||||||
|
{assets.code === 'BUSY'
|
||||||
|
? 'The shard is busy with another client-file request.'
|
||||||
|
: 'The shard is not answering for client files.'}
|
||||||
|
</strong>
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||||
{assets.reason}
|
{assets.code === 'BUSY'
|
||||||
|
? 'The shard serves one of these at a time, so an import running now — or the' +
|
||||||
|
' item-picture pass refilling itself after a client patch — holds it until it is' +
|
||||||
|
' done. This page re-reads once on its own; if the counts below are still missing' +
|
||||||
|
' after that, reload in a moment.'
|
||||||
|
: assets.reason}
|
||||||
{assets.code === 'DISABLED' &&
|
{assets.code === 'DISABLED' &&
|
||||||
' — set Bridge.AssetsEnabled on the shard to allow it to read its own client files.'}
|
' — set Bridge.AssetsEnabled on the shard to allow it to read its own client files.'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -957,3 +957,20 @@ UPDATE uo_link_config SET protocol = 8
|
|||||||
WHERE id = 1 AND protocol < 8
|
WHERE id = 1 AND protocol < 8
|
||||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_8_migrated');
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_8_migrated');
|
||||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_8_migrated', '1');
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_8_migrated', '1');
|
||||||
|
|
||||||
|
-- `shard_spawn_points.unique_id` for an install that already had the table
|
||||||
|
-- (Asset Bridge phase 9; the column itself is Events phase 12b).
|
||||||
|
--
|
||||||
|
-- The column was added to the CREATE TABLE above and nowhere else, so it reached
|
||||||
|
-- fresh installs and no existing one -- `CREATE TABLE IF NOT EXISTS` does not add
|
||||||
|
-- a column to a table that is already there, which is what every ALTER in this
|
||||||
|
-- file exists to do. `replaceAtlas` inserts `unique_id` unconditionally, so on an
|
||||||
|
-- upgraded install EVERY spawn-atlas import since v1.2.0 has failed outright with
|
||||||
|
-- `Unknown column 'unique_id' in 'INSERT INTO'` -- the bestiary, the spawn map and
|
||||||
|
-- the champion altars all frozen at whatever was last imported.
|
||||||
|
--
|
||||||
|
-- Found by the phase 9 acceptance walk, on a rig whose tables predate 12b: a fresh
|
||||||
|
-- install cannot reproduce it, and neither can a test whose schema is this file
|
||||||
|
-- applied to an empty database. That is the same blind spot the protocol-pin block
|
||||||
|
-- above records, two phases running.
|
||||||
|
ALTER TABLE shard_spawn_points ADD COLUMN IF NOT EXISTS unique_id VARCHAR(64) NULL;
|
||||||
|
|||||||
Reference in New Issue
Block a user