fix: robust published article restore on container rebuild
Browse files- Add detailed logging to pullPublishedAssets for diagnostics
- Retry restore for anonymous visitors (dataset is public, no token needed)
- Add mutex to prevent concurrent pull attempts
- Add direct HTTP fetch fallback when Hub library fails
- Log SPACE_ID and HF_DATASET_ID at startup
Made-with: Cursor
- backend/src/hf-storage.ts +37 -10
- backend/src/server.ts +30 -15
backend/src/hf-storage.ts
CHANGED
|
@@ -149,40 +149,67 @@ export async function pullPublishedAssets(
|
|
| 149 |
docName: string,
|
| 150 |
localDir: string,
|
| 151 |
): Promise<boolean> {
|
| 152 |
-
if (!HF_DATASET_ID)
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
-
const accessToken =
|
|
|
|
| 156 |
|
| 157 |
const safeName = sanitizeName(docName);
|
| 158 |
const base = `published/${safeName}`;
|
| 159 |
const files = ["index.html", "article.pdf", "thumb.jpg", "meta.json"];
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
let foundAny = false;
|
| 162 |
|
| 163 |
for (const file of files) {
|
| 164 |
try {
|
|
|
|
| 165 |
const res = await downloadFile({
|
| 166 |
repo,
|
| 167 |
path: `${base}/${file}`,
|
| 168 |
accessToken,
|
| 169 |
});
|
| 170 |
-
if (!res)
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
-
const { mkdirSync, writeFileSync } = await import("fs");
|
| 173 |
-
const { join } = await import("path");
|
| 174 |
-
const outDir = join(localDir, "published", safeName);
|
| 175 |
mkdirSync(outDir, { recursive: true });
|
| 176 |
-
|
| 177 |
const buf = Buffer.from(await res.arrayBuffer());
|
| 178 |
writeFileSync(join(outDir, file), buf);
|
| 179 |
console.log(`[hf-storage] pulled ${base}/${file} (${buf.length} bytes)`);
|
| 180 |
foundAny = true;
|
| 181 |
-
} catch {
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
}
|
| 184 |
}
|
| 185 |
|
|
|
|
| 186 |
return foundAny;
|
| 187 |
}
|
| 188 |
|
|
|
|
| 149 |
docName: string,
|
| 150 |
localDir: string,
|
| 151 |
): Promise<boolean> {
|
| 152 |
+
if (!HF_DATASET_ID) {
|
| 153 |
+
console.log("[hf-storage] pullPublished skipped: no dataset ID");
|
| 154 |
+
return false;
|
| 155 |
+
}
|
| 156 |
|
| 157 |
+
const token = getToken();
|
| 158 |
+
const accessToken = token || undefined;
|
| 159 |
+
console.log(`[hf-storage] pullPublished for "${docName}" from dataset ${HF_DATASET_ID} (token: ${token ? "yes" : "none - public access"})`);
|
| 160 |
|
| 161 |
const safeName = sanitizeName(docName);
|
| 162 |
const base = `published/${safeName}`;
|
| 163 |
const files = ["index.html", "article.pdf", "thumb.jpg", "meta.json"];
|
| 164 |
|
| 165 |
+
const { mkdirSync, writeFileSync } = await import("fs");
|
| 166 |
+
const { join } = await import("path");
|
| 167 |
+
const outDir = join(localDir, "published", safeName);
|
| 168 |
+
|
| 169 |
let foundAny = false;
|
| 170 |
|
| 171 |
for (const file of files) {
|
| 172 |
try {
|
| 173 |
+
// Try Hub library first
|
| 174 |
const res = await downloadFile({
|
| 175 |
repo,
|
| 176 |
path: `${base}/${file}`,
|
| 177 |
accessToken,
|
| 178 |
});
|
| 179 |
+
if (!res) {
|
| 180 |
+
console.log(`[hf-storage] ${base}/${file}: not found via Hub lib`);
|
| 181 |
+
continue;
|
| 182 |
+
}
|
| 183 |
|
|
|
|
|
|
|
|
|
|
| 184 |
mkdirSync(outDir, { recursive: true });
|
|
|
|
| 185 |
const buf = Buffer.from(await res.arrayBuffer());
|
| 186 |
writeFileSync(join(outDir, file), buf);
|
| 187 |
console.log(`[hf-storage] pulled ${base}/${file} (${buf.length} bytes)`);
|
| 188 |
foundAny = true;
|
| 189 |
+
} catch (err) {
|
| 190 |
+
console.warn(`[hf-storage] Hub lib failed for ${base}/${file}:`, (err as Error).message);
|
| 191 |
+
|
| 192 |
+
// Fallback: direct HTTP fetch (works for public datasets without auth)
|
| 193 |
+
try {
|
| 194 |
+
const url = `https://huggingface.co/datasets/${HF_DATASET_ID}/resolve/main/${base}/${file}`;
|
| 195 |
+
console.log(`[hf-storage] trying direct fetch: ${url}`);
|
| 196 |
+
const httpRes = await fetch(url);
|
| 197 |
+
if (!httpRes.ok) {
|
| 198 |
+
console.log(`[hf-storage] direct fetch ${file}: ${httpRes.status}`);
|
| 199 |
+
continue;
|
| 200 |
+
}
|
| 201 |
+
mkdirSync(outDir, { recursive: true });
|
| 202 |
+
const buf = Buffer.from(await httpRes.arrayBuffer());
|
| 203 |
+
writeFileSync(join(outDir, file), buf);
|
| 204 |
+
console.log(`[hf-storage] pulled ${base}/${file} via direct fetch (${buf.length} bytes)`);
|
| 205 |
+
foundAny = true;
|
| 206 |
+
} catch (fetchErr) {
|
| 207 |
+
console.warn(`[hf-storage] direct fetch also failed for ${file}:`, (fetchErr as Error).message);
|
| 208 |
+
}
|
| 209 |
}
|
| 210 |
}
|
| 211 |
|
| 212 |
+
console.log(`[hf-storage] pullPublished result: ${foundAny ? "restored" : "nothing found"}`);
|
| 213 |
return foundAny;
|
| 214 |
}
|
| 215 |
|
backend/src/server.ts
CHANGED
|
@@ -35,10 +35,12 @@ const DEFAULT_DOC_NAME = "default";
|
|
| 35 |
|
| 36 |
/**
|
| 37 |
* Lazy restore of published assets from HF dataset.
|
| 38 |
-
* Called at startup
|
| 39 |
-
*
|
| 40 |
*/
|
| 41 |
let _publishedRestored = false;
|
|
|
|
|
|
|
| 42 |
async function ensurePublishedRestored(token?: string): Promise<void> {
|
| 43 |
if (_publishedRestored) return;
|
| 44 |
if (!getDatasetId()) return;
|
|
@@ -49,17 +51,29 @@ async function ensurePublishedRestored(token?: string): Promise<void> {
|
|
| 49 |
return;
|
| 50 |
}
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
if (token) setUserToken(token);
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
|
| 63 |
}
|
| 64 |
|
| 65 |
mkdirSync(DATA_DIR, { recursive: true });
|
|
@@ -384,11 +398,11 @@ if (existsSync(staticDir)) {
|
|
| 384 |
return;
|
| 385 |
}
|
| 386 |
|
| 387 |
-
// Try lazy-restore
|
|
|
|
|
|
|
| 388 |
const visitorToken = extractToken(req.headers.cookie) ?? undefined;
|
| 389 |
-
|
| 390 |
-
await ensurePublishedRestored(visitorToken);
|
| 391 |
-
}
|
| 392 |
|
| 393 |
// In production with OAuth: serve the published article if it exists
|
| 394 |
const publishedPath = getPublishedPath(DEFAULT_DOC_NAME);
|
|
@@ -408,8 +422,9 @@ if (existsSync(staticDir)) {
|
|
| 408 |
const server = httpServer.listen(PORT, async () => {
|
| 409 |
console.log(`[server] running on http://localhost:${PORT}`);
|
| 410 |
console.log(`[server] collab websocket at ws://localhost:${PORT}/collab/:doc`);
|
|
|
|
| 411 |
|
| 412 |
-
//
|
| 413 |
await ensurePublishedRestored();
|
| 414 |
});
|
| 415 |
|
|
|
|
| 35 |
|
| 36 |
/**
|
| 37 |
* Lazy restore of published assets from HF dataset.
|
| 38 |
+
* Called at startup and on every request until successful.
|
| 39 |
+
* Dataset is public so no token is required for reads.
|
| 40 |
*/
|
| 41 |
let _publishedRestored = false;
|
| 42 |
+
let _restoreInProgress: Promise<void> | null = null;
|
| 43 |
+
|
| 44 |
async function ensurePublishedRestored(token?: string): Promise<void> {
|
| 45 |
if (_publishedRestored) return;
|
| 46 |
if (!getDatasetId()) return;
|
|
|
|
| 51 |
return;
|
| 52 |
}
|
| 53 |
|
| 54 |
+
// Prevent concurrent pulls (multiple requests arriving at the same time)
|
| 55 |
+
if (_restoreInProgress) {
|
| 56 |
+
await _restoreInProgress;
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
if (token) setUserToken(token);
|
| 61 |
|
| 62 |
+
_restoreInProgress = (async () => {
|
| 63 |
+
try {
|
| 64 |
+
const found = await pullPublishedAssets(DEFAULT_DOC_NAME, DATA_DIR);
|
| 65 |
+
if (found) {
|
| 66 |
+
_publishedRestored = true;
|
| 67 |
+
console.log("[server] restored published article from HF dataset");
|
| 68 |
+
}
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.warn("[server] failed to restore published:", (err as Error).message);
|
| 71 |
+
} finally {
|
| 72 |
+
_restoreInProgress = null;
|
| 73 |
}
|
| 74 |
+
})();
|
| 75 |
+
|
| 76 |
+
await _restoreInProgress;
|
| 77 |
}
|
| 78 |
|
| 79 |
mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
| 398 |
return;
|
| 399 |
}
|
| 400 |
|
| 401 |
+
// Try lazy-restore on every request until successful.
|
| 402 |
+
// Dataset is public, so no token is needed for reads.
|
| 403 |
+
// An OAuth token is passed when available for extra reliability.
|
| 404 |
const visitorToken = extractToken(req.headers.cookie) ?? undefined;
|
| 405 |
+
await ensurePublishedRestored(visitorToken);
|
|
|
|
|
|
|
| 406 |
|
| 407 |
// In production with OAuth: serve the published article if it exists
|
| 408 |
const publishedPath = getPublishedPath(DEFAULT_DOC_NAME);
|
|
|
|
| 422 |
const server = httpServer.listen(PORT, async () => {
|
| 423 |
console.log(`[server] running on http://localhost:${PORT}`);
|
| 424 |
console.log(`[server] collab websocket at ws://localhost:${PORT}/collab/:doc`);
|
| 425 |
+
console.log(`[server] SPACE_ID=${process.env.SPACE_ID || "(not set)"} HF_DATASET_ID=${getDatasetId() || "(empty)"} HF_TOKEN=${process.env.HF_TOKEN ? "set" : "not set"}`);
|
| 426 |
|
| 427 |
+
// Eager restore: dataset is public, works without token
|
| 428 |
await ensurePublishedRestored();
|
| 429 |
});
|
| 430 |
|