Как удалить файл, пока на него могут появиться новые ссылки
Чтобы удалить неиспользуемый файл, нужно знать, какие документы на него ссылаются. Есть и второе условие: новая ссылка не должна появиться, пока файл удаляется или уже удалён. Поэтому очистку файлов приходится согласовывать с сохранением документов. Если ответ базы потерялся, нужно ещё установить, не появилась ли ссылка, о которой приложение пока не знает.
Учебные примеры подготовлены в октябре 2026 года. В них использованы современные возможности JavaScript и платформы; архивная дата не относится к этим реализациям.
В редакторе блога такая гонка возможна между двумя вкладками: в одной удаляют фотографию, в другой сохраняют черновик, куда её уже вставили. Проверка ссылок перед удалением может пройти раньше сохранения. Оба запроса завершатся успешно, а в черновике останется ссылка на отсутствующий файл.
Если сохранение статьи выполнится первым, новая ссылка должна помешать удалению. Если первым начнётся удаление, сохранить ссылку уже нельзя.
Проверка, которая уже опоздала
Сначала воспроизведём ошибку без базы данных и настоящих изображений. records хранит документы, bytes обозначает существующие файлы. Два промиса позволят остановить удаление точно после проверки и продолжить его после сохранения статьи.
import assert from 'node:assert/strict';
const records = new Map();
const bytes = new Set(['photo']);
const checked = Promise.withResolvers();
const resume = Promise.withResolvers();
const removal = (async () => {
if ([...records.values()].includes('photo')) {
return;
}
checked.resolve();
await resume.promise;
bytes.delete('photo');
})();
await checked.promise;
records.set('post', 'photo');
resume.resolve();
await removal;
assert.equal(records.get('post'), 'photo');
assert.equal(bytes.has('photo'), false);
Промисы задают точный порядок: проверка ссылок, сохранение статьи, удаление файла. Пример воспроизводит ошибку независимо от скорости компьютера. Теперь нужно сделать так, чтобы сохранение не могло вклиниться между проверкой и удалением.
Повторный поиск непосредственно перед удалением лишь передвинет место ошибки. Между новым поиском и удалением снова останется промежуток. Можно поставить блокировку, но она должна быть общей для добавления ссылки и удаления файла. Блокировка только внутри remove() защитит от второго удаления, а сохранение статьи по-прежнему пройдёт рядом.
О чём договариваются запись и удаление
Ограничим задачу одним активным процессом приложения. Все изменения ссылок проходят через него, посторонние программы файлы не удаляют. Идентификаторы удалённых файлов не выдаются новым загрузкам. Это существенные условия: локальная очередь не сможет остановить запись из другого процесса.
Для начала поместим запись документа и удаление файла в общую очередь. Если первой пришла запись, удаление дождётся её результата и увидит новую ссылку. Если первым пришло удаление, оно запретит новые ссылки прежде, чем обратится к файловой системе. Запись, дождавшаяся своей очереди, получит отказ.
Состояния будем хранить в каталоге файлов. Для каждого файла в нём будет запись с идентификатором и статусом. На файл со статусом ready можно ссылаться. Статус deleting запрещает новые ссылки и показывает, что файл нужно удалить. Когда удаление закончится, уберём и запись из каталога. После ошибки статус останется deleting: мы повторим попытку, не делая файл доступным для новых статей.
| Состояние | Новая ссылка | Действие удаления |
|---|---|---|
ready, есть ссылка |
Разрешена | Оставить файл |
ready, ссылок нет |
Зависит от того, кто первым вошёл в очередь | Перевести в deleting |
deleting |
Запрещена | Удалить байты, затем запись из каталога |
| Записи в каталоге нет | Запрещена | Повторная команда удаления допустима |
Состояние deleting нужно сохранить до обращения к файловой системе. Иначе приложение может остановиться после удаления файла, пока в каталоге ещё стоит ready. После перезапуска оно разрешит ссылаться на отсутствующую фотографию.
Получилась последовательность с промежуточным состоянием. Общей транзакции между базой данных и файловой системой у неё нет. Зато после остановки между шагами понятно, что можно повторить.
Ответа нет, но запись ещё может выполниться
Теперь более неприятный случай. Приложение отправило команду сохранения документа и не дождалось ответа. Мы освободили очередь, нашли в базе ноль ссылок и удалили файл. Затем до базы дошла прежняя команда сохранения.
У такого сбоя есть и другой вариант: документ уже сохранён, но ответ потерялся. По одной ошибке запроса приложение не всегда может отличить эти ситуации. Например, MongoDB прямо указывает, что ошибка ожидания подтверждения wtimeout не отменяет уже выполненные изменения.
С точки зрения очереди запрос закончился ошибкой и можно начинать следующий. Но для базы команда сохранения ещё может быть действующей. Если сразу разрешить удаление, прежняя команда сможет добавить ссылку уже после него.
Поэтому перед отправкой команды оставим в хранилище запись о предстоящем изменении: какой документ, с какой ожидаемой версией и на какой файл собирается сослаться. Дальше будем называть её намерением. Пока результат команды неизвестен, намерение запрещает удаление файла. Оно должно переживать остановку приложения, поэтому обычного Set в памяти для такой защиты недостаточно.
Удалять намерение в finally нельзя: этот блок выполнится и при неизвестном результате. Намерение убираем, когда знаем результат команды или отдельно исключили возможность поздней записи. Если выяснить это пока не удаётся, файл остаётся. Я предпочту временно хранить лишнюю фотографию, чем сохранить статью со ссылкой на удалённую.
Как запретить запоздалое сохранение
У каждого документа будет версия. Сохранение проверяет, совпадает ли она с ожидаемой, и при совпадении заменяет содержимое и версию вместе. Это одна атомарная операция хранилища. Два отдельных запроса «прочитать версию» и «записать содержимое» оставили бы ещё одну гонку.
Такое сравнение можно выразить условием обновления. В MongoDB условие проверяется при атомарном обновлении документа. Здесь же построим маленькую модель на JavaScript, чтобы управлять порядком операций без настоящей базы данных.
Сохраните следующий код в model.js. Он предполагает запуск как ES-модуль. replace() меняет ссылку, если версия совпала, а fence() меняет только версию. Старое содержимое при этом сохраняется.
import { randomUUID } from 'node:crypto';
export function createDocuments(records) {
return {
read(id) {
const record = records.get(id);
if (!record) {
throw new Error('Document not found');
}
return { ...record };
},
replace(id, version, file) {
const record = records.get(id);
if (!record || record.version !== version) {
return false;
}
records.set(id, { file, version: randomUUID() });
return true;
},
fence(id, version) {
const record = records.get(id);
if (!record || record.version !== version) {
return false;
}
records.set(id, { ...record, version: randomUUID() });
return true;
},
references(file) {
return [...records.values()].some(record => record.file === file);
}
};
}
У модели нет await между сравнением и заменой записи, поэтому другое действие не вклинится в середину. В настоящем хранилище такую неделимость нужно получить от его операции обновления. Замена records.get() и records.set() на два сетевых вызова смысл примера разрушит.
Предположим, зависшая запись ожидала версию v1. При восстановлении попробуем поменять эту версию, не трогая содержимое. Дальше возможны два порядка.
Если первой выполнилась прежняя запись, документ уже получил новую версию вместе со ссылкой. fence() ничего не поменяет, а последующий поиск найдёт ссылку и не даст удалить файл. Если первой выполнилась fence(), старая запись больше не пройдёт сравнение с v1. Она может добраться до базы хоть после удаления файла, но применить ссылку уже не сможет.
Обе операции проверяют одну и ту же версию, и примениться сможет только одна. После подтверждённого ответа fence() прежняя команда либо уже выполнена, либо больше не пройдёт проверку версии. Теперь можно убрать намерение и снова проверить ссылки.
Версии нельзя переиспользовать. После подтверждённого восстановления ожидаемая версия не должна вернуться из-за отката хранилища. Это требуется и тогда, когда fence() ответила, что версия уже другая: сам ответ ещё не доказывает устойчивость увиденного изменения. Чтение ссылок должно видеть подтверждённые изменения; случайная старая копия данных для решения об удалении не подходит. Если эти гарантии не получены или сама попытка сменить версию закончилась неизвестным результатом, намерение остаётся до следующего восстановления.
Собираем запись и удаление в одном месте
Добавим в тот же model.js функцию, которая владеет общей очередью. В этой модели все документы уже существуют и содержат одну необязательную ссылку. Значение null означает, что файл отвязали от документа.
files хранит состояния файлов, pending — журнал незавершённых намерений. Обе структуры здесь имитируют устойчивое хранилище: после пересоздания приложения мы передадим ему те же объекты. Это позволит проверить порядок восстановления, но не станет проверкой сохранности данных после сбоя питания.
Параметр request нужен для воспроизведения сетевой неопределённости. По умолчанию он выполняет команду и возвращает её результат. В проверках заменим его функцией, которая может потерять ответ или оставить команду на потом.
export function createLibrary({
files,
pending,
documents,
storage,
request = async action => action()
}) {
let current = Promise.resolve();
function serialize(action) {
const result = current.then(action);
current = result.then(
() => undefined,
() => undefined
);
return result;
}
function write(document, file) {
return serialize(async () => {
if (file !== null && files.get(file) !== 'ready') {
throw new Error('File is unavailable');
}
if ([...pending.values()].some(entry => entry.document === document)) {
throw new Error('Previous write is unresolved');
}
const previous = documents.read(document);
const token = randomUUID();
const entry = { document, file, version: previous.version };
pending.set(token, entry);
const changed = await request(() => documents.replace(document, entry.version, file));
pending.delete(token);
if (!changed) {
throw new Error('Document has changed');
}
});
}
function recover() {
return serialize(async () => {
for (const [token, entry] of pending) {
await request(() => documents.fence(entry.document, entry.version));
pending.delete(token);
}
});
}
function remove(file) {
return serialize(async () => {
if (!files.has(file)) {
return 'removed';
}
if ([...pending.values()].some(entry => entry.file === file)) {
throw new Error('Write result is unknown');
}
if (documents.references(file)) {
return 'in-use';
}
files.set(file, 'deleting');
await storage.remove(file);
files.delete(file);
return 'removed';
});
}
return { write, recover, remove };
}
В serialize() ошибка предыдущей операции не ломает очередь. При этом вызывающий код получает исходный result с исходной ошибкой. Мы продолжаем обслуживать следующие операции, не выдавая неудачную запись за успешную.
В write() отказ из-за версии и исключение обрабатываются по-разному. Если версия не совпала, команда уже ответила, что не применила изменение, и намерение можно убрать. При исключении выполнение до pending.delete() не дойдёт: пример считает результат неизвестным и сохраняет защиту файла. Адаптер может отдельно различать ошибки, при которых запись точно не отправлялась, но для этого нужны сведения от самого хранилища.
Восстановление проходит через ту же очередь. Пока оно разбирает старое намерение, новая запись этого документа не начнётся. Если запрос смены версии завершился ошибкой, исключение прервёт recover(), а намерение останется защищать файл. Удаление не должно обходить эту проверку потому, что журнал давно не очищался.
Проверяем позднюю запись после удаления
В отдельном check.js создадим искусственные хранилища и потеряем результат запроса ещё до выполнения команды. Никаких таймеров не требуется: команду сохранения удерживает переменная delayed.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createDocuments, createLibrary } from './model.js';
const files = new Map([['photo', 'ready']]);
const pending = new Map();
const records = new Map([
['post', { file: null, version: randomUUID() }]
]);
const bytes = new Set(['photo']);
const documents = createDocuments(records);
const storage = {
async remove(file) {
bytes.delete(file);
}
};
const state = { files, pending, documents, storage };
let delayed;
const library = createLibrary({
...state,
request(action) {
delayed = action;
return Promise.reject(new Error('Timeout'));
}
});
await assert.rejects(library.write('post', 'photo'), /Timeout/);
await assert.rejects(library.remove('photo'), /unknown/);
assert.equal(pending.size, 1);
const restarted = createLibrary(state);
await restarted.recover();
assert.equal(pending.size, 0);
assert.equal(await restarted.remove('photo'), 'removed');
assert.equal(delayed(), false);
assert.equal(documents.references('photo'), false);
Если в папке с примером нет настройки ES-модулей, добавьте package.json с содержимым {"type":"module"}. Затем выполните node check.js. Пример использует Node.js 26.12.1. При успешной проверке команда завершится без ошибки.
Мы пересоздали объект приложения, сохранив его внешние хранилища. Старый объект больше не принимает новые запросы, но уже отправленная им команда ещё может дойти до базы. Последняя проверка воспроизводит именно это: байты удалены, запоздалая команда вызвана и отвергнута по версии.
Теперь заменим тело request(), чтобы команда успела выполниться, а ответ потерялся:
request(action) {
action();
return Promise.reject(new Error('Reply lost'));
}
Для этого варианта замените и проверки в конце check.js, начиная с первого assert.rejects(), а затем запустите файл заново со свежими хранилищами:
await assert.rejects(library.write('post', 'photo'), /Reply lost/);
await assert.rejects(library.remove('photo'), /unknown/);
assert.equal(pending.size, 1);
const restarted = createLibrary(state);
await restarted.recover();
assert.equal(pending.size, 0);
assert.equal(await restarted.remove('photo'), 'in-use');
assert.equal(bytes.has('photo'), true);
Документ уже ссылается на фотографию, поэтому байты остаются. Обработчик запроса видел ошибку в обоих вариантах, но решение об удалении получилось разным. Его определило подтверждённое состояние после восстановления.
Если приложение остановилось посреди удаления
При физическом удалении порядок тоже имеет значение. Сначала мы записали deleting, затем убрали файл и только после этого удалили запись из каталога. Если переставить последние действия, остановка приложения оставит файл без записи, по которой его можно найти и дочистить.
| Где произошла остановка | Что осталось | Как продолжить |
|---|---|---|
После deleting, до удаления байтов |
Файл и запись deleting |
Повторить удаление |
| После удаления байтов, до очистки каталога | Только запись deleting |
Принять отсутствие файла и убрать запись |
| После очистки каталога | Ни файла, ни записи | При повторе команды подтвердить, что удаление уже выполнено |
| Во время записи документа | Намерение с неизвестным исходом | Сначала сменить ожидаемую версию и проверить ссылки |
Удаление должно допускать повторный вызов. Если файла по указанному пути уже нет, нужный результат достигнут. Но ошибка доступа означает совсем другое, и превращать любой отказ файловой системы в успех нельзя.
import { unlink } from 'node:fs/promises';
export async function remove(path) {
try {
await unlink(path);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
}
Это обёртка над удалением файла в Node.js, которая добавляет нужное поведение при повторе. Путь берётся из собственного каталога приложения, а не из произвольного параметра запроса. Имена файлов не переиспользуются: иначе повтор старой команды мог бы удалить новую загрузку по тому же пути.
При любой другой ошибке запись останется в deleting, а вызывающий код получит исключение. Можно исправить причину и повторить попытку. Заодно приложение не разрешит сохранить новую ссылку на файл, удаление которого уже началось.
Что потребуется от настоящего хранилища
Модель оставляет за скобками устройство журнала на диске, но требование к нему конкретное: подтверждённое намерение должно переживать остановку приложения. Если результат сохранения самого намерения неизвестен, отправлять вслед за ним запись документа нельзя. Если после перезапуска журнал не удалось надёжно прочитать, удаление файлов тоже недоступно. Ошибка чтения не означает, что незавершённых операций нет.
То же относится к состоянию deleting. Прежде чем удалять байты, нужно подтвердить его сохранение. Неизвестный результат изменения метаданных требует восстановления, а не перехода к следующему шагу по оптимистичному предположению. Именно эти гарантии имитируют обычные операции с Map в примере.
Устойчивость нужна и изменениям документов. Если база сначала подтвердит отвязку фотографии, а после удаления файла откатит эту запись, ссылка появится снова. Например, MongoDB допускает откат записей при смене основного узла, если выбранное подтверждение не обеспечивает их сохранность. Для настоящего адаптера нужно согласовать гарантии записи и чтения документов, версий и журнала. Одного атомарного сравнения версии для этого недостаточно.
Схема работает, пока всеми изменениями управляет один процесс. При нескольких процессах у каждого окажется своя очередь, и записи снова смогут выполняться одновременно с удалением. Их придётся согласовывать через общее хранилище. Блокировки со временем жизни самой по себе недостаточно: после её истечения прежняя операция всё ещё может завершиться.
У реального документа может быть несколько изображений и ссылки на их варианты. В намерении нужно защищать весь набор файлов, которые добавляем, а поиск должен учитывать все места, где ссылка считается действующей. Например, файл в черновике всё ещё нужен редактору. Проверка только опубликованных статей сделает удаление неверным ещё до всякой гонки.
В примере с двумя вкладками очередь определит, что выполнится первым. Запись статьи сохранит фотографию от удаления, а начавшееся удаление не позволит сохранить новую ссылку. Если ответ базы потеряется, намерение удержит файл до выяснения результата, а проверка версии не даст поздней команде вернуть ссылку после удаления. Теперь отсутствие ссылок можно использовать для решения об удалении: мы учли и существующие документы, и ещё не законченные запросы.