diff --git a/src/workerd/api/unsafe.c++ b/src/workerd/api/unsafe.c++ index b847660e492..bdfc8824fd4 100644 --- a/src/workerd/api/unsafe.c++ +++ b/src/workerd/api/unsafe.c++ @@ -204,6 +204,16 @@ jsg::Promise UnsafeModule::abortAllDurableObjects(jsg::Lock& js) { return js.resolvedPromise(); } +jsg::Promise UnsafeModule::deleteAllDurableObjects(jsg::Lock& js) { + auto& context = IoContext::current(); + + auto exception = + JSG_KJ_EXCEPTION(FAILED, Error, "Application called deleteAllDurableObjects()."); + context.deleteAllActors(exception); + + return js.resolvedPromise(); +} + bool UnsafeModule::isTestAutogateEnabled() { return util::Autogate::isEnabled(util::AutogateKey::TEST_WORKERD); } diff --git a/src/workerd/api/unsafe.h b/src/workerd/api/unsafe.h index 77880576afa..1d354df81e7 100644 --- a/src/workerd/api/unsafe.h +++ b/src/workerd/api/unsafe.h @@ -100,12 +100,17 @@ class UnsafeModule: public jsg::Object { UnsafeModule(jsg::Lock&, const jsg::Url&) {} jsg::Promise abortAllDurableObjects(jsg::Lock& js); + // Aborts all Durable Objects and deletes all of their underlying storage, including alarms. + // After this call, DOs can be recreated with clean state. Useful for test isolation. + jsg::Promise deleteAllDurableObjects(jsg::Lock& js); + // Returns true if the TEST_WORKERD autogate is enabled. // This is used to verify that the all-autogates test variant is working correctly. bool isTestAutogateEnabled(); JSG_RESOURCE_TYPE(UnsafeModule) { JSG_METHOD(abortAllDurableObjects); + JSG_METHOD(deleteAllDurableObjects); JSG_METHOD(isTestAutogateEnabled); } }; diff --git a/src/workerd/io/io-channels.h b/src/workerd/io/io-channels.h index 179209b10a4..ae2affc64e5 100644 --- a/src/workerd/io/io-channels.h +++ b/src/workerd/io/io-channels.h @@ -276,6 +276,12 @@ class IoChannelFactory { KJ_UNIMPLEMENTED("Only implemented by single-tenant workerd runtime"); } + // Aborts all actors and deletes all underlying storage (including alarms) for all evictable + // namespaces. After this call, DOs can be recreated with clean state. + virtual void deleteAllActors(kj::Maybe reason) { + KJ_UNIMPLEMENTED("Only implemented by single-tenant workerd runtime"); + } + // Use a dynamic Worker loader binding to obtain an Worker by name. If name is null, or if the named Worker doesn't already exist, the callback will be called to fetch the source code from which the Worker should be created. virtual kj::Own loadIsolate(uint loaderChannel, kj::Maybe name, diff --git a/src/workerd/io/io-context.h b/src/workerd/io/io-context.h index 25a18596c38..78de1be7eb4 100644 --- a/src/workerd/io/io-context.h +++ b/src/workerd/io/io-context.h @@ -858,6 +858,10 @@ class IoContext final: public kj::Refcounted, private kj::TaskSet::ErrorHandler getIoChannelFactory().abortAllActors(reason); } + void deleteAllActors(kj::Maybe reason) { + getIoChannelFactory().deleteAllActors(reason); + } + // Get an HttpClient to use for Cache API subrequests. kj::Own getCacheClient(); diff --git a/src/workerd/server/server.c++ b/src/workerd/server/server.c++ index 265a7d88a1f..46690d9f0c4 100644 --- a/src/workerd/server/server.c++ +++ b/src/workerd/server/server.c++ @@ -1901,6 +1901,7 @@ class Server::WorkerService final: public Service, using LinkCallback = kj::Function; using AbortActorsCallback = kj::Function reason)>; + using DeleteActorsCallback = kj::Function reason)>; WorkerService(ChannelTokenHandler& channelTokenHandler, kj::Maybe serviceName, @@ -1912,6 +1913,7 @@ class Server::WorkerService final: public Service, kj::HashSet actorClassEntrypointsParam, LinkCallback linkCallback, AbortActorsCallback abortActorsCallback, + DeleteActorsCallback deleteActorsCallback, kj::Maybe dockerPathParam, kj::Maybe containerEgressInterceptorImageParam, bool isDynamic) @@ -1926,6 +1928,7 @@ class Server::WorkerService final: public Service, actorClassEntrypoints(kj::mv(actorClassEntrypointsParam)), waitUntilTasks(*this), abortActorsCallback(kj::mv(abortActorsCallback)), + deleteActorsCallback(kj::mv(deleteActorsCallback)), dockerPath(kj::mv(dockerPathParam)), containerEgressInterceptorImage(kj::mv(containerEgressInterceptorImageParam)), isDynamic(isDynamic) {} @@ -2945,6 +2948,19 @@ class Server::WorkerService final: public Service, actors.clear(); } + // Aborts all actors and deletes all underlying storage for this namespace. + void deleteAll(kj::Maybe reason) { + // First, abort all running actors so they release their file handles. + abortAll(reason); + + // Then delete all files in the namespace's storage directory. + KJ_IF_SOME(as, actorStorage) { + for (auto& entry: as.directory->listNames()) { + as.directory->remove(kj::Path({entry})); + } + } + } + private: kj::Own actorClass; const ActorConfig& config; @@ -3238,6 +3254,7 @@ class Server::WorkerService final: public Service, kj::HashMap> actorNamespaces; kj::TaskSet waitUntilTasks; AbortActorsCallback abortActorsCallback; + DeleteActorsCallback deleteActorsCallback; kj::Maybe dockerPath; kj::Maybe containerEgressInterceptorImage; bool isDynamic; @@ -3454,6 +3471,10 @@ class Server::WorkerService final: public Service, abortActorsCallback(reason); } + void deleteAllActors(kj::Maybe reason) override { + deleteActorsCallback(reason); + } + kj::Own loadIsolate(uint loaderChannel, kj::Maybe name, kj::Function()> fetchSource) override; @@ -3994,6 +4015,27 @@ void Server::abortAllActors(kj::Maybe reason) { alarmScheduler->deleteAllAlarms(); } +void Server::deleteAllActors(kj::Maybe reason) { + for (auto& service: services) { + if (WorkerService* worker = dynamic_cast(&*service.value)) { + for (auto& [className, ns]: worker->getActorNamespaces()) { + bool isEvictable = true; + KJ_SWITCH_ONEOF(ns->getConfig()) { + KJ_CASE_ONEOF(c, Durable) { + isEvictable = c.isEvictable; + } + KJ_CASE_ONEOF(c, Ephemeral) { + isEvictable = c.isEvictable; + } + } + if (isEvictable) ns->deleteAll(reason); + } + } + } + + alarmScheduler->deleteAllAlarms(); +} + // WorkerDef is an intermediate representation of everything from `config::Worker::Reader` that // `Server::makeWorkerImpl()` needs. Similar to `WorkerSource`, we factor out this intermediate // representation so that we can potentially build it dynamically from input that isn't a @@ -4853,7 +4895,8 @@ kj::Promise> Server::makeWorkerImpl(kj::StringPtr kj::refcounted(channelTokenHandler, serviceName, globalContext->threadContext, monotonicClock, kj::mv(worker), kj::mv(errorReporter.defaultEntrypoint), kj::mv(errorReporter.namedEntrypoints), kj::mv(errorReporter.actorClasses), - kj::mv(linkCallback), KJ_BIND_METHOD(*this, abortAllActors), kj::mv(dockerPath), + kj::mv(linkCallback), KJ_BIND_METHOD(*this, abortAllActors), + KJ_BIND_METHOD(*this, deleteAllActors), kj::mv(dockerPath), kj::mv(containerEgressInterceptorImage), def.isDynamic); result->initActorNamespaces(def.localActorConfigs, network); co_return result; diff --git a/src/workerd/server/server.h b/src/workerd/server/server.h index 6162b1107f8..e2f6b710225 100644 --- a/src/workerd/server/server.h +++ b/src/workerd/server/server.h @@ -256,6 +256,10 @@ class Server final: private kj::TaskSet::ErrorHandler, private ChannelTokenHandl // Aborts all actors in this server except those in namespaces marked with `preventEviction`. void abortAllActors(kj::Maybe reason); + // Aborts all actors, deletes all alarms, and deletes all underlying storage for evictable + // namespaces. After this, DOs can be recreated with clean state. + void deleteAllActors(kj::Maybe reason); + // Can only be called in the link stage. // // May return a new object or may return a fake-own around a long-lived object. diff --git a/src/workerd/server/tests/unsafe-module/unsafe-module-test.js b/src/workerd/server/tests/unsafe-module/unsafe-module-test.js index 72f36142fcf..1ddb87f2a0a 100644 --- a/src/workerd/server/tests/unsafe-module/unsafe-module-test.js +++ b/src/workerd/server/tests/unsafe-module/unsafe-module-test.js @@ -19,6 +19,15 @@ export const TestEphemeralObjectPreventEviction = createTestObject( 'ephemeral-prevent-eviction' ); +export class StorageObject extends DurableObject { + async getValue() { + return (await this.ctx.storage.get('key')) ?? null; + } + async setValue(value) { + await this.ctx.storage.put('key', value); + } +} + let alarmTriggers = 0; export class AlarmObject extends DurableObject { get scheduledTime() { @@ -120,3 +129,56 @@ export const test_abort_all_durable_objects_alarms = { assert.strictEqual(alarmTriggers, 1); // (same as before) }, }; + +export const test_delete_all_durable_objects = { + async test(ctrl, env, ctx) { + // Write some data to a durable object. + const id = env.STORAGE.idFromName('test-delete'); + let stub = env.STORAGE.get(id); + await stub.setValue('hello'); + assert.strictEqual(await stub.getValue(), 'hello'); + + // Delete all durable objects. + await unsafe.deleteAllDurableObjects(); + + // Old stub should be broken. + await assert.rejects(() => stub.getValue(), { + name: 'Error', + message: 'Application called deleteAllDurableObjects().', + }); + + // Recreate the stub — storage should be gone. + stub = env.STORAGE.get(id); + assert.strictEqual(await stub.getValue(), null); + }, +}; + +export const test_delete_all_durable_objects_alarms = { + async test(ctrl, env, ctx) { + const id = env.ALARM.newUniqueId(); + const stub = env.ALARM.get(id); + + const alarmsBefore = alarmTriggers; + await stub.scheduleIn(500); + assert.notStrictEqual(await stub.scheduledTime, null); + + // Delete everything — alarms included. + await unsafe.deleteAllDurableObjects(); + await scheduler.wait(1000); + assert.strictEqual(alarmTriggers, alarmsBefore); // alarm did not fire + }, +}; + +export const test_delete_all_durable_objects_respects_prevent_eviction = { + async test(ctrl, env, ctx) { + const id = env.DURABLE_PREVENT_EVICTION.newUniqueId(); + const stub = env.DURABLE_PREVENT_EVICTION.get(id); + const res1 = await (await stub.fetch('http://x')).text(); + + await unsafe.deleteAllDurableObjects(); + + // preventEviction namespace should be untouched — same response (same instance). + const res2 = await (await stub.fetch('http://x')).text(); + assert.strictEqual(res1, res2); + }, +}; diff --git a/src/workerd/server/tests/unsafe-module/unsafe-module-test.wd-test b/src/workerd/server/tests/unsafe-module/unsafe-module-test.wd-test index f4f1b715833..daedd2195f5 100644 --- a/src/workerd/server/tests/unsafe-module/unsafe-module-test.wd-test +++ b/src/workerd/server/tests/unsafe-module/unsafe-module-test.wd-test @@ -15,6 +15,7 @@ const unitTests :Workerd.Config = ( ( className = "TestEphemeralObject", ephemeralLocal = void ), ( className = "TestEphemeralObjectPreventEviction", ephemeralLocal = void, preventEviction = true ), ( className = "AlarmObject", uniqueKey = "alarm" ), + ( className = "StorageObject", uniqueKey = "storage" ), ], durableObjectStorage = ( localDisk = "TEST_TMPDIR" ), bindings = [ @@ -23,6 +24,7 @@ const unitTests :Workerd.Config = ( ( name = "EPHEMERAL", durableObjectNamespace = "TestEphemeralObject" ), ( name = "EPHEMERAL_PREVENT_EVICTION", durableObjectNamespace = "TestEphemeralObjectPreventEviction" ), ( name = "ALARM", durableObjectNamespace = "AlarmObject" ), + ( name = "STORAGE", durableObjectNamespace = "StorageObject" ), ], ) ),