mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-01 03:26:18 +00:00
* 🧹 fix: Stop Undeletable Files Starving the Retention Sweep `getExpiredFiles` returns the oldest `expiredAt` first, capped at `limit`, and `processDeleteRequest` leaves the record in place when storage deletion fails. Nothing records the failure, so the same files come back at the head of the next batch an hour later, forever: no backoff, no cap, and — once `limit` of them cannot be deleted — no file that expires afterwards is ever swept again. On the deployment behind #15511 that is ~29k stranded objects permanently occupying a 100-slot queue, which is why fixing the Code Interpreter side alone (LibreChat-AI/code-interpreter#85) would not have resumed deletion there. Failures are now recorded on the file. `deletionRetryAt` holds it back with a backoff doubling from one sweep interval to a day, and `deletionAttempts` retires it from the query entirely once it reaches `FILE_RETENTION_SWEEP_MAX_ATTEMPTS` (10, so roughly five days of retries). Both fields are absent on existing records and absence means "never attempted", so nothing already in the collection changes eligibility. The record itself is kept rather than deleted — the reference is what an operator needs to reconcile a bucket the sweep could not clear, and dropping it would restore the silence that made this leak invisible. Raising `FILE_RETENTION_SWEEP_MAX_ATTEMPTS` re-admits everything previously given up on, which is the supported way to resume once the storage-side failure is fixed; the give-up is logged with that instruction. The counter is incremented server-side with `$inc` so concurrent sweeps on separate nodes cannot overwrite each other's progress toward the cap, and a failure to record a failure is logged and skipped rather than aborting the rest of the batch. * 🧭 fix: Delete Code Environment Files Through the Route That Exists `deleteCodeEnvFile` tried `/sessions/:sid/objects/:fid` before falling back to `/files/:sid/:fid`. Both have been there since #13424, but only the second is mounted by any released codeapi — the first gained DELETE in LibreChat-AI/code-interpreter#85 — so every deletion paid a guaranteed 404 and a wasted round trip, and #15511 read that 404 as the whole bug. Call `/files/:sid/:fid` directly. It is the safe direction to collapse toward: codeapi has mounted it since its first release, so this works against older deployments as well as post-#85 ones, whereas keeping the other path would not. Collapsing the loop tightens two behaviours that only existed to serve it. A 405 now surfaces instead of being swallowed on the way to a second attempt; there is no second route to try, and a service that refuses the method should say so. A 404 is still treated as "already gone" — that is the only thing it can now mean — but it is logged rather than passed over in silence, because a 404 caused by a misconfigured base URL looks identical and this branch drops the file's metadata record either way. * 🔒 fix: Settle sweep retry state from the write, not the read Two findings from the review of0e52924. - `recordFailure` derived the attempt number by adding one to the count the batch had queried. Two nodes sweeping the same file read the same value, so both believed themselves to be the same attempt: each `$inc` landed, the stored count crossed `FILE_RETENTION_SWEEP_MAX_ATTEMPTS`, and neither caller ever saw the threshold. The file drops out of the query — the starvation guard still holds — but the give-up is never reported, and that log line is the operator's only notice, and carries the instruction for resuming. Return the count from the increment itself so every caller gets a distinct attempt number and exactly one observes the cap. The same staleness shortened the backoff, so `deferExpiredFile` now writes with `$max`: a deferral can only move later, and a node that computed a shorter delay cannot pull the file forward past one another node already committed. - The backoff doubled from a hard-coded hour while claiming to start from one sweep interval. At the default they coincide; away from it the schedule stops meaning anything — on a six-hour sweep the first three attempts all land on consecutive passes, and on a five-minute one the first retry skips twelve. Derive the base from `FILE_RETENTION_SWEEP_INTERVAL_MS`, floored at a minute so a pathologically short interval cannot spend the whole give-up budget on a transient outage. * 🧯 fix: Keep the give-up notice and the retry budget honest Four findings from the review ofee1bac5. - The give-up was reported after the deferral write, inside the same catch. Once the increment lands the counter is durable, so `getExpiredFiles` already excludes the file; a deferral that then failed left only the generic recording error and dropped the one line naming the file and saying how to resume. Report it as soon as the increment returns, and let the deferral fail on its own. - Retry deadlines were measured from `Date.now()` after the deletion I/O, but `startExpiredFileSweep` arms its interval before the sweep runs. A one-interval delay therefore expired just *after* the next scheduled pass, which skipped the file and pushed its first retry out by a whole extra interval — and the same drift applied to every delay that is an exact multiple. Anchor deadlines to the sweep's start instead. - The threshold used `>=`, so once two nodes pushed a file past the cap every one of them past it logged the give-up: one error per replica per exhausted file rather than one actionable notice. Only the attempt that lands exactly on the cap reports it. - Retry state outlived the content it described. `processCodeOutput` reuses a record for a repeated `(filename, conversationId)` — new bytes, new storage key, and a fresh `expiredAt` from `getRetentionExpiry` — while `createFile` and `updateFile` set only supplied fields. A record carried to the cap by its previous content stayed excluded from the sweep forever, stranding the new object exactly as this PR set out to prevent; a partially failed one started the new object's budget already spent. Both write paths now clear the fields: the budget belongs to the storage a record currently points at. The retry-state fixtures in `file.spec.ts` were seeded through `createFile`, which now clears them, so they drive the real methods the sweep uses. * 🎯 fix: Clear the retry budget only when a retention lifecycle starts Both findings from the review of8d26f9b, and both are consequences of that commit's reset rather than of the original change. - The reset was unconditional on every `createFile`/`updateFile`, on the reasoning that those paths write content. Two of them do not. `prepareImages{Local,Azure,Firebase}` call `updateFile({ file_id })` with nothing but the id — a TTL touch — every time an existing image is encoded for another chat, and the deferred preview uses the same method to transition `status`. Either handed a stranded record a fresh set of attempts and another give-up notice, on repeat, defeating the cap for the files most likely to be stranded. Gate it on the write actually setting `expiredAt`. The budget belongs to a retention lifecycle: that is the write which starts a new one, it is what `processCodeOutput` supplies when it repurposes a record, and a record with no retention deadline is never swept, so the fields are inert there anyway. - Both retry writes bumped `updatedAt`. `processCodeOutput` falls back to `updatedAt` as the writer-order stamp for records that predate `metadata.sourceDispatchedAt`, so a failed sweep landing mid-harvest read as a newer content writer and the harvest dropped its attachment. Mark them `timestamps: false`, for the reason `claimCodeFile` already does: bookkeeping is not a content write. * 🅿️ refactor: Park exhausted files instead of excluding them Three review rounds in a row found defects in how the give-up cap interacts with record reuse, each in the fix for the last. That is a design error, not a bug list: a permanent exclusion has to be bound precisely to the content lifecycle it was recorded against, and File records outlive their content. `processCodeOutput` repurposes a row for a repeated `(filename, conversationId)`, `createFile`/`updateFile` set only supplied fields, and `getRetentionExpiry` returns `{}` on a lookup failure so the row inherits its old deadline — three separate ways for bookkeeping to survive into a lifecycle it does not describe, each needing its own guard, and the two retry writes needing to be lifecycle-conditional on top. Remove the category instead. `deletionRetryAt` becomes the sweep's only hold, and reaching `FILE_RETENTION_SWEEP_MAX_ATTEMPTS` parks the file for a month rather than excluding it. The bound on the batch is the same — a stranded file costs one slot a month instead of one an hour — but a deadline that outlives its content can only delay the next object, never lose it, so nothing outside the sweep has to reason about this state at all. That deletes more than it adds: - `createFile` and `updateFile` go back to their original form. No reset, so no question of which writes install content, and `prepareImages*` and the deferred preview stop mattering here. - `getExpiredFiles` loses `maxAttempts` and its `$and`; eligibility is one `$or` on the deadline. - The interleaving race between the increment and the deferral degrades from a stranded object to a delayed one. Working through a large backlog is throughput-bound either way: every attempt costs a slot in the bounded batch, so N stranded files need N × `FILE_RETENTION_SWEEP_MAX_ATTEMPTS` passes to settle. Lower that limit when recovering a deployment that has accumulated many.
347 lines
13 KiB
JavaScript
347 lines
13 KiB
JavaScript
const FormData = require('form-data');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { getCodeBaseURL } = require('@librechat/agents');
|
|
const { EModelEndpoint, getCodeEnvRefs } = require('librechat-data-provider');
|
|
const {
|
|
logAxiosError,
|
|
appendCodeEnvFile,
|
|
createAxiosInstance,
|
|
codeServerHttpAgent,
|
|
codeServerHttpsAgent,
|
|
appendCodeEnvFileIdentity,
|
|
buildCodeEnvDownloadQuery,
|
|
getCodeApiAuthHeaders,
|
|
getCodeExecutionBaseUrl,
|
|
createCodeExecutionRouteKey,
|
|
codeExecutionHeaders,
|
|
} = require('@librechat/api');
|
|
|
|
const axios = createAxiosInstance();
|
|
|
|
const MAX_FILE_SIZE = 150 * 1024 * 1024;
|
|
|
|
/**
|
|
* Retrieves a download stream for a specified file.
|
|
* @param {string} fileIdentifier - The identifier for the file (e.g., "session_id/fileId").
|
|
* @param {{ kind: 'skill' | 'agent' | 'user'; id: string; version?: number }} identity
|
|
* Resource identity required by codeapi's `sessionAuth` to derive the
|
|
* matching sessionKey. For code-output downloads this is always
|
|
* `kind: 'user', id: <userId>`; for skill/agent re-downloads pass
|
|
* the kind+id (+version for skill) from the file's `metadata.codeEnvRef`.
|
|
* @param {ServerRequest} req - Current authenticated request.
|
|
* @param {{baseUrl?: string, executionProfile?: 'default'|'stateful', bridgeWorkerId?: string}} [route]
|
|
* Trusted host-selected Code API route.
|
|
* @returns {Promise<AxiosResponse>} A promise that resolves to a readable stream of the file content.
|
|
* @throws {Error} If there's an error during the download process.
|
|
*/
|
|
async function getCodeOutputDownloadStream(fileIdentifier, identity, req, route = {}) {
|
|
try {
|
|
const baseURL = route.baseUrl ?? getCodeBaseURL();
|
|
const query = buildCodeEnvDownloadQuery(identity);
|
|
const authHeaders = await getCodeApiAuthHeaders(req, route.bridgeWorkerId);
|
|
/** @type {import('axios').AxiosRequestConfig} */
|
|
const options = {
|
|
method: 'get',
|
|
url: `${baseURL}/download/${fileIdentifier}${query}`,
|
|
responseType: 'stream',
|
|
headers: {
|
|
'User-Agent': 'LibreChat/1.0',
|
|
...authHeaders,
|
|
...(route.executionProfile
|
|
? codeExecutionHeaders({
|
|
executionProfile: route.executionProfile,
|
|
bridgeWorkerId: route.bridgeWorkerId,
|
|
})
|
|
: {}),
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 15000,
|
|
};
|
|
|
|
const response = await axios(options);
|
|
return response;
|
|
} catch (error) {
|
|
throw new Error(
|
|
logAxiosError({
|
|
message: `Error downloading code environment file stream: ${error.message}`,
|
|
error,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a file from the Code Environment server.
|
|
*
|
|
* @param {ServerRequest} req - Current authenticated request, used to mint Code API auth.
|
|
* @param {MongoFile} file - File metadata containing `metadata.codeEnvRef`.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function deleteCodeEnvFile(req, file) {
|
|
const refs = getCodeEnvRefs(file?.metadata);
|
|
if (refs.length === 0) {
|
|
return;
|
|
}
|
|
|
|
for (const [executionRouteKey, ref] of refs) {
|
|
const executionProfile = ref.executionProfile ?? 'default';
|
|
const environments =
|
|
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments;
|
|
const configuredEnvironment = environments?.find(
|
|
(environment) =>
|
|
createCodeExecutionRouteKey(executionProfile, environment) === executionRouteKey,
|
|
);
|
|
if (
|
|
executionProfile === 'stateful' &&
|
|
executionRouteKey !== executionProfile &&
|
|
!configuredEnvironment
|
|
) {
|
|
logger.warn(
|
|
`[deleteCodeEnvFile] Skipping remote cleanup for unmapped historical route ${executionRouteKey}`,
|
|
);
|
|
continue;
|
|
}
|
|
let baseURL;
|
|
try {
|
|
baseURL = getCodeExecutionBaseUrl(executionProfile, configuredEnvironment);
|
|
} catch (error) {
|
|
if (
|
|
executionProfile === 'stateful' &&
|
|
executionRouteKey === executionProfile &&
|
|
!configuredEnvironment
|
|
) {
|
|
logger.warn(
|
|
'[deleteCodeEnvFile] Skipping remote cleanup for retired legacy stateful route',
|
|
);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
const query = buildCodeEnvDownloadQuery({
|
|
kind: ref.kind,
|
|
id: ref.id,
|
|
...(ref.kind === 'skill' ? { version: ref.version } : {}),
|
|
});
|
|
const bridgeWorkerId =
|
|
configuredEnvironment?.workerId ?? configuredEnvironment?.pairing?.workerId;
|
|
const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId);
|
|
/* codeapi has mounted DELETE at `/files/:session_id/:fileId` since its
|
|
* first release. The file-server's own `/sessions/:id/objects/:fileId`
|
|
* only gained DELETE in LibreChat-AI/code-interpreter#85, so trying it
|
|
* first cost a guaranteed 404 against every older deployment. */
|
|
try {
|
|
await axios({
|
|
method: 'delete',
|
|
url: `${baseURL}/files/${ref.storage_session_id}/${ref.file_id}${query}`,
|
|
headers: {
|
|
'User-Agent': 'LibreChat/1.0',
|
|
...authHeaders,
|
|
...codeExecutionHeaders({
|
|
executionProfile,
|
|
bridgeWorkerId,
|
|
}),
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 15000,
|
|
});
|
|
} catch (error) {
|
|
if (error.response?.status !== 404) {
|
|
throw error;
|
|
}
|
|
/* Already gone. Logged rather than swallowed: a 404 from a
|
|
* misconfigured base URL is indistinguishable from one for an absent
|
|
* object, and this branch drops the file's record either way. */
|
|
logAxiosError({
|
|
error,
|
|
message: `Code environment object already absent: ${error.message}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploads a file to the Code Environment server.
|
|
*
|
|
* `kind`/`id`/`version?` are required so codeapi can route the upload to
|
|
* the correct sessionKey bucket — `<tenant>:<kind>:<id>[:v:<version>]`
|
|
* for shared kinds, `<tenant>:user:<authContext.userId>` for `user`.
|
|
* Without these, codeapi falls back to user-scoped bucketing regardless
|
|
* of the resource the file belongs to, so skill-cache invalidation
|
|
* (driven by the version bump on edit) never fires. See codeapi #1455.
|
|
*
|
|
* @param {Object} params - The params object.
|
|
* @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
|
|
* @param {import('fs').ReadStream | import('stream').Readable} params.stream - The read stream for the file.
|
|
* @param {string} params.filename - The name of the file.
|
|
* @param {'skill' | 'agent' | 'user'} params.kind - Resource kind that owns this file's storage session.
|
|
* @param {string} params.id - Resource id (skillId / agentId / userId). Codeapi
|
|
* ignores this for `kind: 'user'` (auth context provides userId), but it's
|
|
* sent uniformly for shape symmetry with the discriminated union.
|
|
* @param {number} [params.version] - Required when `kind === 'skill'`; absent otherwise.
|
|
* @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
|
|
* @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
|
|
* @param {string} [params.bridgeWorkerId] - Trusted worker selected for this execution.
|
|
* @returns {Promise<{ storage_session_id: string; file_id: string }>}
|
|
* The codeapi storage location of the uploaded file.
|
|
* @throws {Error} If there's an error during the upload process.
|
|
*/
|
|
async function uploadCodeEnvFile({
|
|
req,
|
|
stream,
|
|
filename,
|
|
kind,
|
|
id,
|
|
version,
|
|
codeApiBaseUrl,
|
|
executionProfile,
|
|
bridgeWorkerId,
|
|
}) {
|
|
try {
|
|
const form = new FormData();
|
|
appendCodeEnvFileIdentity(form, { kind, id, version });
|
|
appendCodeEnvFile(form, stream, filename);
|
|
|
|
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
|
const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId);
|
|
/** @type {import('axios').AxiosRequestConfig} */
|
|
const options = {
|
|
headers: {
|
|
...form.getHeaders(),
|
|
'Content-Type': 'multipart/form-data',
|
|
'User-Agent': 'LibreChat/1.0',
|
|
'User-Id': req.user.id,
|
|
...authHeaders,
|
|
...(executionProfile ? codeExecutionHeaders({ executionProfile, bridgeWorkerId }) : {}),
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 120000,
|
|
maxContentLength: MAX_FILE_SIZE,
|
|
maxBodyLength: MAX_FILE_SIZE,
|
|
};
|
|
|
|
const response = await axios.post(`${baseURL}/upload`, form, options);
|
|
|
|
/** @type {{ message: string; storage_session_id: string; files: Array<{ fileId: string; filename: string }> }} */
|
|
const result = response.data;
|
|
if (result.message !== 'success') {
|
|
throw new Error(`Error uploading file: ${result.message}`);
|
|
}
|
|
|
|
return {
|
|
storage_session_id: result.storage_session_id,
|
|
file_id: result.files[0].fileId,
|
|
};
|
|
} catch (error) {
|
|
throw new Error(
|
|
logAxiosError({
|
|
message: `Error uploading code environment file: ${error.message}`,
|
|
error,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploads multiple files to the code execution environment in a single request.
|
|
* Uses the /upload/batch endpoint which shares one session_id across all files.
|
|
*
|
|
* `kind`/`id`/`version?` carry the resource identity for codeapi's sessionKey
|
|
* derivation — see `uploadCodeEnvFile` for the full motivation.
|
|
*
|
|
* @param {object} params
|
|
* @param {import('express').Request & { user: { id: string } }} params.req - The request object.
|
|
* @param {Array<{ stream: NodeJS.ReadableStream; filename: string }>} params.files - Files to upload.
|
|
* @param {'skill' | 'agent' | 'user'} params.kind - Resource kind that owns the batch's storage session.
|
|
* @param {string} params.id - Resource id (skillId / agentId / userId).
|
|
* @param {number} [params.version] - Required when `kind === 'skill'`; absent otherwise.
|
|
* @param {boolean} [params.read_only] - When true, codeapi tags every file in
|
|
* the batch as infrastructure (e.g. skill files). The flag is persisted as
|
|
* MinIO object metadata (`X-Amz-Meta-Read-Only`) and travels with the file
|
|
* through subsequent download/walk passes — sandboxed-code modifications
|
|
* are dropped on the floor and the original ref is echoed back as
|
|
* `inherited: true`, never as a generated artifact.
|
|
* @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
|
|
* @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
|
|
* @param {string} [params.bridgeWorkerId] - Trusted worker selected for this execution.
|
|
* @returns {Promise<{ storage_session_id: string; files: Array<{ fileId: string; filename: string }> }>}
|
|
* @throws {Error} If the batch upload fails entirely.
|
|
*/
|
|
async function batchUploadCodeEnvFiles({
|
|
req,
|
|
files,
|
|
kind,
|
|
id,
|
|
version,
|
|
read_only = false,
|
|
codeApiBaseUrl,
|
|
executionProfile,
|
|
bridgeWorkerId,
|
|
}) {
|
|
const form = new FormData();
|
|
appendCodeEnvFileIdentity(form, { kind, id, version });
|
|
if (read_only) {
|
|
form.append('read_only', 'true');
|
|
}
|
|
for (const file of files) {
|
|
appendCodeEnvFile(form, file.stream, file.filename);
|
|
}
|
|
|
|
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
|
const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId);
|
|
/** @type {import('axios').AxiosRequestConfig} */
|
|
const options = {
|
|
headers: {
|
|
...form.getHeaders(),
|
|
'Content-Type': 'multipart/form-data',
|
|
'User-Agent': 'LibreChat/1.0',
|
|
'User-Id': req.user.id,
|
|
...authHeaders,
|
|
...(executionProfile ? codeExecutionHeaders({ executionProfile, bridgeWorkerId }) : {}),
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 120000,
|
|
maxContentLength: MAX_FILE_SIZE,
|
|
maxBodyLength: MAX_FILE_SIZE,
|
|
};
|
|
|
|
const response = await axios.post(`${baseURL}/upload/batch`, form, options);
|
|
|
|
/** @type {{ message: string; storage_session_id: string; files: Array<{ status: string; fileId?: string; filename: string; error?: string }>; succeeded: number; failed: number }} */
|
|
const result = response.data;
|
|
if (
|
|
!result ||
|
|
typeof result !== 'object' ||
|
|
!result.storage_session_id ||
|
|
!Array.isArray(result.files)
|
|
) {
|
|
throw new Error(`Unexpected batch upload response: ${JSON.stringify(result).slice(0, 200)}`);
|
|
}
|
|
if (result.message === 'error') {
|
|
throw new Error('All files in batch upload failed');
|
|
}
|
|
|
|
if (result.failed > 0) {
|
|
const failedNames = result.files
|
|
.filter((f) => f.status === 'error')
|
|
.map((f) => `${f.filename}: ${f.error || 'unknown'}`)
|
|
.join(', ');
|
|
logger.warn(`[batchUploadCodeEnvFiles] ${result.failed} file(s) failed: ${failedNames}`);
|
|
}
|
|
|
|
const successFiles = result.files
|
|
.filter((f) => f.status === 'success' && f.fileId)
|
|
.map((f) => ({ fileId: f.fileId, filename: f.filename }));
|
|
|
|
return { storage_session_id: result.storage_session_id, files: successFiles };
|
|
}
|
|
|
|
module.exports = {
|
|
deleteCodeEnvFile,
|
|
getCodeOutputDownloadStream,
|
|
uploadCodeEnvFile,
|
|
batchUploadCodeEnvFiles,
|
|
};
|