Phase 3: cheaper hard deletes¶
Parent plan: PLAN-deleted-object-sweep.md.
Planning effort: high, as the master plan specifies. This phase adds
a DatabaseService RPC that deletes rows from up to a dozen tables in
one transaction, decides in SQL which objects are still eligible, and
changes what the elected maintainer does with most of its sweep. A
wrong table list deletes too little and leaks rows; a wrong guard
deletes objects that are still in use.
Decisions continue the plan's single sequence: phase 2 used D7-D13, so this phase starts at D14.
Scope¶
In:
- A batch hard delete for the object types whose
hard_delete()is nothing but row deletes keyed on the object: one RPC, one transaction per batch, with the state and age re-check moved into the transaction (D14). - Those classes' own
hard_delete()moving onto the batch, so each type's table list exists once and every caller's hard delete of them is one commit, with a test that proves the move deletes exactly what the old code deleted (D15). - The sweep using the batch for those types, and the existing per-object path for the rest (D16, D17).
- A cap on objects hard deleted per pass, so this phase changes the cost of the sweep and not its rate (D18).
- The sweep running on every maintenance pass, which phase 2 meant it to and which it does not (D19).
- Metrics for the batch, documentation, release notes.
- After merge and deploy, 24 hours of sfcbr data, recorded by phase 4's planning commit.
Out:
- Instance, namespace, namespace claim and node. Their own
hard_delete()code is unchanged and keeps its per-object semantics. Where a namespace cascade test asserted a batchable child's individual deletes, it now asserts the child's one batch call (D15). - Draining sfcbr's backlog at a planned rate, and the regression guard. Phase 4. This phase ships the knob (D18) with a default close to today's rate.
- Load-budget entries for the sweep's new RPC pair (D23).
-
3922 (errored objects are never hard deleted). It adds arrivals, so¶
it still waits for phase 4.
What the survey found¶
The survey read the master plan's phase 3 section against develop at
1b55ef2dae and against sfcbr's 24 hours on phase 2, ending
2026-10-23T09:10Z. The full numbers are in the master plan under "What
phase 2 measured". The findings below are the ones this phase is
planned from. False claims were corrected at their
source in this planning commit, so a later step need not redo it.
-
The per-commit reading is all but confirmed. Every delete that removes a row now costs 40 to 50 ms, whatever it removes:
DeleteObjectState40 ms,DeleteClusterOperation41 ms,DeleteBlobHashes(several rows) 43 ms,DeleteNamespace44 ms. Deletes that usually remove nothing cost what a read costs:DeleteObjectMetadata3.2 ms,DeleteClusterOperationError2.0 ms. Cost tracks "this statement wrote", not rows written. That is the shape of a redo log flush per commit underinnodb_flush_log_at_trx_commit=1. Phase 1's 50 to 250 ms spread came from a different mix and a busier tier; the floor is the same. The batch's own latency against its size (3g) is the last word. -
An operation's hard delete is three such commits, about 135 ms.
node_inst_opcosts 0.138 s,net_op0.139 s, and the other operation types 0.13 to 0.15 s. They are 14 of the 29 types and about 70% of the backlog. -
Most types' hard deletes are nothing but row deletes keyed on the object. The inventory, with the functions each calls before the base class's three (
delete_state,delete_object_metadata,delete_object_events,shakenfist/baseobject.py:759-763):
| Types | Class | Own deletes |
|---|---|---|
the 14 *_op types |
BaseClusterOperation.hard_delete (operations/baseoperation.py:268) |
delete_cluster_operation_target, delete_cluster_operation_error, delete_cluster_operation |
agentoperation |
operations/agentoperation.py:553 |
delete_agent_operation_attributes, delete_agent_operation, remove_all_references_from (it derives from BaseOperation, which has no override, so not the cluster operation three) |
artifact |
artifact.py:747 |
delete_all_artifact_indexes, delete_artifact_attributes, delete_artifact, then delete_cluster_operation_targets_for_object (baseobject.py:835) |
network |
network/network.py:813 |
delete_network_attributes, delete_network, then delete_cluster_operation_targets_for_object |
blob |
blob.py:1029 |
delete_blob_hashes, delete_blob_transfers_for_blob, delete_blob_attributes, delete_blob |
dhcp |
managed_executables/dnsmasq.py:125 |
delete_dnsmasq |
interface |
network/interface.py:302 |
delete_network_interface_attributes, delete_network_interface |
ipam |
ipam.py:428 |
delete_reservations_for_ipam, delete_ipam (an in-memory IPAM returns early; one read from the database never is) |
namespace_key, trusted_issuer, mapping_rule |
namespace_key.py:424, trusted_issuer.py:292, mapping_rule.py:605 |
their attributes delete, then their static delete |
upload |
upload.py:149 |
delete_upload |
None of them reads anything from the hydrated object but its uuid and type. None takes a lock or touches a file. The table is the management session's reading of a sub-agent inventory, spot-checked at the line numbers given; brief 3c's parity test is what makes it authoritative.
- Four types are not pure deletes.
instance(instance.py:1740) first runs_release_placement(), a capacity transaction that reads the hydrated object's claim.namespace(namespace.py:344) cascades to its keys, mapping rules and claims, and rewrites other namespaces' trust attributes.namespace_claim(namespace_claim.py:381) returns capacity in a guarded transaction and raisesWriteExceptionon failure.-
node(node.py:881) deliberately does nothing. -
Each delete is its own commit today. Every
_direct_delete_*opensengine.connect(), runs one statement and commits (mariadb.py:4311,5501,21788). Multi-statement transactions usewith engine.begin(), wrapped in_retry_transaction()(mariadb.py:26619);_direct_delete_orphaned_object_states(mariadb.py:25081) is the closest precedent, a bulk delete onobject_statesin one transaction. No existing RPC deletes rows from several tables for several objects at once. -
DELETE ... RETURNINGis available. Shaken Fist requires MariaDB 10.11 or later and refuses MySQL (docs/operator_guide/database.md, "MariaDB compatibility requirements"). SQLAlchemy 2.1.5's MariaDB dialect reportsdelete_returningand compilesDELETE FROM object_states WHERE ... RETURNING object_states.object_uuid. MariaDB has noUPDATE ... RETURNING(the comment atmariadb.py:27669), which does not matter here. -
Every table the batch deletes from is keyed for it.
event_objects' primary key is(object_type, object_uuid, event_uuid)(mariadb.py:2841), so anobject_uuid IN (...)delete for one type is an index range per uuid. The other tables are keyed on the uuid column the_direct_*function already uses. -
The sweep runs every other maintenance pass, not every minute. sf-cluster logs a pass every 2 minutes 8 seconds on sfcbr, each using its full 20 seconds. The maintenance loop runs scheduled jobs when
now - last_loop_run >= 60(daemons/cluster/main.py, in_run_inner).schedulesets a job's next run to its interval after the job finishes. A 20 second sweep started at T is next due at T+80, misses the pass at T+60, and runs at T+120. Phase 2's D10 meant every minute; the sweep has half the duty cycle it was given. Short jobs registeredevery(1).minutesare unaffected, because they finish within the loop's poll interval. -
Reads are cheap in latency but not in load. Phase 1 dropped "skip the hydration" as a candidate because reads cost 2 ms. They do, but each is a database request, and sf-cluster's
GetObjectStateis at 4.01/s, up from 2.54/s, against a budget of 1.986/s plus 0.016/s per instance, with a tolerance of twice that. Most of the excess is the sweep's per-object re-checks. A faster per-object sweep would push that pair, and the per-objectDelete*pairs, further over. A batch that does the re-check in SQL makes none of those requests. -
The keyset walk is healthy. Phase 2's top risk, the paged read choosing a sort over the primary key, did not happen: a page costs 2 to 12 ms for every type. The hourly count is the slow read, at 3 s for
node_inst_op, which is acceptable once an hour. -
There are 29 object types, not 28.
OBJECT_NAMES_TO_CLASSEShas had 29 entries since before this plan began; the master plan, phase 1's and phase 2's plans, two comments inscheduled_tasks.pyand a test docstring say 28. The plans are corrected in this planning commit; the code comments are corrected by step 3e, which edits that file anyway. -
An equal share leaves the two busiest types growing. Every type with a backlog got about 5,100 objects a day.
namespacetook 8,705 arrivals andnode_inst_op7,441, so their backlogs grew by 3,594 and 2,356.net_opheld level. Fixing the cadence alone would double the share. D18's default sets the floor.
Decisions¶
D14 -- Batch the pure-delete types in one transaction, re-checked in SQL¶
Add one RPC, HardDeleteObjects(object_type, object_uuids,
state_values, updated_before), returning the uuids it deleted. In one
transaction it:
- deletes the
object_statesrows for those uuids of that type whose state is one ofstate_valuesand whoseupdate_timeis older thanupdated_before,RETURNING object_uuid; - deletes, for exactly the returned uuids, every other row that
type's
hard_delete()removes (D15's table list), including theevent_objectsrows; - commits once.
The first statement is the eligibility check that the per-object path
does with from_db() and a state read, made atomic with the delete:
an object which left its final state, or was touched again, after the
sweep listed it is simply not returned, and nothing else of it is
deleted. It is also the guarded DML statement a transaction here must
open with (see _direct_release_instance_placement's comment), so no
plain SELECT runs first.
This reverses the master plan's "dropping the hydration is no longer a candidate" for these types, for survey finding 9's reason: the reads are cheap per call and expensive in aggregate. On the batch path the sweep makes one request per batch instead of six to ten per object.
A second mode serves hard_delete() itself (D15): unconditional
set, with no states and no age. It deletes the state rows and every
mapped row for every uuid it is given, without keying the later
statements on what the first returned, because a caller of
hard_delete() has already decided and an object can lack a state
row. It returns the uuids it was given. The request must name one mode
or the other; the server refuses a guarded request without states or
an age, and an unconditional one with either, so a caller mistake
cannot turn the sweep's guard off.
The server rejects more than 1,000 uuids in one call, so a caller mistake cannot build an unbounded transaction.
D15 -- Each batchable class's hard_delete() moves onto the batch¶
The batch needs, for each batchable type, the statements its
hard_delete() runs. Planning weighed two ways to keep them:
- Rewrite every batchable
hard_delete()onto the batch delete, so each class's own deletes become one call with a list of one. One copy, and every caller's hard delete becomes one commit. But it changes every caller at once, including the namespace cascade and its tests, and it makes this phase's measurement a measurement of every daemon's hard deletes. - Keep
hard_delete()as it is and declare a second copy of the list beside the SQL, with a test between the two.
The plan first chose the second, for the cascade tests and a cleaner measurement. Michael chose the first at the back brief (2026-10-23). What follows from it:
- The list lives once, in
mariadb.pybeside the SQL. It maps each batchable type to the ordered names of the publicmariadbdelete functions itshard_delete()calls today, excluding the base three, and each name to one SQL statement that mirrors that function's_direct_implementation, and to a call of the public function for one uuid. DatabaseBackedObject.hard_delete()sends a batchable type tohard_delete_objects(type, [uuid], unconditional=True)(D14's second mode), then writes the audit event. The subclasses' own row deletes go.IPAM's early return for an in-memory IPAM stays, andInstancekeeps the cluster operation targets delete it inherits.- Against an sf-database without the RPC,
hard_delete()falls back to calling the mapped public functions one at a time and then the base three, which is what today's code does. The fallback can go once every supported sf-database serves the RPC; the master plan's Future work records that. - Step 3c pins the list against today's classes before they change, so the move is proven to delete exactly what the old code deleted. Step 3d moves the classes and keeps that pinned list as a literal in the test, so a later change to a type's deletes is made twice, on purpose.
- Instance, namespace, namespace claim and node keep their code (D16). The namespace cascade's hard delete of its keys and mapping rules becomes one call each.
- This phase's 24 hours measure every daemon's hard deletes changing, not only the sweep's. Brief 3g reads them per daemon.
D16 -- Instance, namespace, namespace claim and node stay per object¶
Survey finding 4. Each has a side effect the batch cannot make
without reimplementing it, and the master plan keeps those cascades'
semantics. node is a deliberate no-op and stays one. Together they
are three of sfcbr's 29 backlogs (namespace 72,768, instance and
namespace_claim near zero), and namespace at 0.25 s an object fits
in the time the batch frees.
D17 -- A turn takes up to one batch of one type¶
The drain keeps phase 2's round robin and rotation. A turn now takes
up to DELETED_OBJECT_BATCH_SIZE entries from the next type with
work: a batchable type deletes them in one call, a per-object type
deletes them one at a time with the time budget checked between
objects. Fairness stays by object count, so a per-object type is not
starved by types whose turn is a single cheap call. The batch size
defaults to 25, small enough that an operation batch is a few hundred
rows and a turn of namespaces fits the time budget.
D18 -- Throttle by objects per pass; this phase changes cost, not rate¶
A batch of 25 operations is one commit instead of 75. Inside phase 2's
20 second budget the sweep could hard delete thousands of objects a
minute, and would, on the first pass after deploy: sfcbr's 1.75M
backlog in hours, as a write load on labqnap nobody has measured.
That is phase 4's drain, which the master plan wants throttled and
watched with sf-db-load.
So a new option, DELETED_OBJECT_SWEEP_MAX_OBJECTS, caps the entries
a pass takes, with the time budget kept as a ceiling. It defaults to
100, about what phase 2 would manage at its intended cadence
(survey finding 8: about 3,200 an hour at every other pass, so 6,000
an hour, 100 a minute, at every pass). That is nearly four times
sfcbr's arrival rate of about 26 a minute, so the backlog still falls.
It also gives each of the 12 types still holding a backlog about
12,000 a day, above the busiest type's arrivals (survey finding 12:
namespace takes 8,705 a day). And phase 3's 24 hours then measure
the same rate at a lower cost, which is the comparison worth having.
Phase 4 raises it.
This is the decision most likely to be argued with: it ships a batch path and then holds it to roughly the old throughput. The alternative, letting the time budget govern, is fine for a cluster with no backlog and is exactly the unmeasured flat-out drain the master plan warned against for one with sfcbr's.
D19 -- The sweep runs on every maintenance pass¶
Register the sweep with a 30 second interval, so that whatever its duration it is due again by the next 60 second pass, and say why in the registration's comment. The loop's own 60 second gate still sets the cadence. Other jobs keep their intervals: they are short enough not to slip.
D20 -- The audit event stays, one per object¶
Each object the batch deletes gets the same EVENT_TYPE_AUDIT 'hard
deleted object' event the base hard_delete() writes, through
eventlog.add_event() with the type and uuid. That call only
enqueues to the local spool (eventlog.py:19); the spool's drainer
ships events in batches through RecordEventBatch, so it adds no
synchronous request.
D21 -- Metrics: keep the per-entry series, add a per-batch one¶
cluster_deleted_object_seconds keeps counting entries by type and
outcome, so phase 2's throughput queries and dashboards still work: a
batch observes each of its entries with an equal share of the batch's
wall time. A new outcome, skipped, counts batch entries the guard
did not return (gone, no longer final, or too young; the batch cannot
tell which). A new histogram, cluster_deleted_object_batch_seconds
by object_type, observes each batch call, which with the entry
counts gives batch latency against batch size (3g).
D22 -- An older sf-database falls back to the per-object path¶
A new sf-cluster calling an sf-database without the RPC gets
UNIMPLEMENTED. The client wrapper raises a distinct exception for it,
as the coalescing wrappers do with CoalescingUnavailable, and the
sweep hard deletes that turn's entries one at a time as phase 2 does,
logging once per pass. hard_delete() has its own fallback (D15),
so the per-object path works against an old sf-database too. The
operator guide already says to upgrade sf-database first.
D23 -- No load-budget entries in this phase¶
The batch removes most of the sweep's per-object pairs, the ones
phase 2's execution notes found above the unbudgeted ceiling, and
adds one pair, HardDeleteObjects from sf-cluster, at most a few
calls a minute at D18's default. A budget entry should be derived
from a measured rate, and the rate is set by D18's cap, which phase 4
moves. Brief 3g checks every sf-cluster pair against the budget after
deploy, and phase 4 adds the entries once it has settled the rate.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 3a | low | sonnet | none | Run the sweep on every maintenance pass (D19). See brief 3a. |
| 3b | high | opus | none | Add HardDeleteObjects (D14, D15's list). See brief 3b. |
| 3c | high | opus | none | Pin the list to today's classes (D15). See brief 3c. |
| 3d | high | opus | none | Move the batchable classes' hard_delete() onto the batch (D15). See brief 3d. |
| 3e | high | opus | none | Batch the sweep, with its throttle (D16-D18, D20-D22). See brief 3e. |
| 3f | low | sonnet | none | Document the changes. See brief 3f. |
| 3g | — | management session | — | After merge and deploy, measure and record. See brief 3g. |
Run the steps in order, one at a time, in this worktree. 3a and 3e
both touch sf-cluster's scheduling and tests, 3b, 3c and 3d all edit
mariadb.py's neighbourhood or the classes it maps, 3c must read the
classes before 3d changes them, and two agents in one tree can undo
each other through pre-commit's stash.
Brief 3a: run the sweep on every maintenance pass¶
In shakenfist/daemons/cluster/main.py, _run_inner() registers
scheduled_tasks.per_deleted_object_checks with
schedule.every(1).minutes. Change it to schedule.every(30).seconds
and rewrite the comment above it to say why: the maintenance loop runs
due jobs once now - last_loop_run >= 60, and schedule sets a
job's next run to its interval after the job finishes, so a job
which runs for its whole DELETED_OBJECT_SWEEP_SECONDS budget and is
registered every minute is not yet due at the next pass and runs only
every other one. On sfcbr that halved the sweep's duty cycle. Keep the
existing reasons in the comment (no thread; the watchdog is petted
between jobs).
Add a unit test that drives the real schedule library through two
consecutive passes 60 seconds apart with a sweep that takes 20
seconds, using a patched clock (schedule reads
datetime.datetime.now; check the installed version's source for
exactly what to patch), and asserts the sweep ran in both. Prefer
registering the job the way _run_inner() does by factoring the
registration of the deleted-object jobs into a small function the
test can call, rather than copying the interval into the test. Show
the test fails with every(1).minutes (mutation) and passes with the
change.
Commit subject: "Run the deleted-object sweep every pass."
Brief 3b: add HardDeleteObjects¶
Use the add-grpc-service skill's pattern for a new
DatabaseService RPC, and read CountObjectsByState (added by
phase 2) as the nearest worked example in each file.
protos/database.proto: rpc HardDeleteObjects
(HardDeleteObjectsRequest) returns (HardDeleteObjectsReply), with
HardDeleteObjectsRequest{ObjectType object_type = 1; repeated string
object_uuids = 2; repeated string state_values = 3; double
updated_before = 4; bool unconditional = 5;} and HardDeleteObjectsReply{repeated string
deleted_uuids = 1;}. Use leading comments, one per field, so the
generated .pyi attaches them to the right fields. Regenerate with
tox -e genprotos.
shakenfist/mariadb.py, the three-layer trio
_direct_hard_delete_objects, _grpc_hard_delete_objects and
public hard_delete_objects(object_type, object_uuids,
state_values=None, updated_before=None, unconditional=False) ->
Optional[list[str]]:
- The public function raises
ValueErrorfor a guarded call with an emptystate_valuesor anupdated_beforeof 0 or None, an unconditional call with either, more thanHARD_DELETE_OBJECTS_MAX(1,000) uuids, or a type with no entry in the list below. An empty uuid list returns[]without a call. - The list: a module-level mapping from
ObjectTypeto the ordered names of the publicmariadbdelete functions that type'shard_delete()calls today, excluding the base three, and a second mapping from each function name to a builder of one SQLAlchemyDELETEstatement for a list of uuids and a call of the public function for one uuid (some take the type as well as the uuid). Each builder mirrors the WHERE clause of that function's_direct_implementation exactly, withINin place of=, and cites it. Survey finding 3's table is the starting point; read every_direct_function you map rather than trusting the table. Types: the 14*_optypes,agentoperation,artifact,network,blob,dhcp,interface,ipam,namespace_key,trusted_issuer,mapping_rule,upload. Notinstance,namespace,namespace_claimornode. - The direct implementation, inside
_retry_transaction()and onewith engine.begin() as conn: firstDELETE FROM object_states WHERE object_type = :t AND object_uuid IN :uuids AND state_value IN :states AND update_time < :before RETURNING object_uuid; if nothing came back, return[]; otherwise run every mapped statement for the returned uuids, then theobject_metadataandevent_objectsdeletes (mirroring_direct_delete_object_metadataand_direct_delete_object_events), and return the returned uuids. No plainSELECTbefore the first statement (see the comment at_direct_release_instance_placement). AnOperationalErrorthat_retry_transaction()does not retry is logged and returns None. In unconditional mode, the mapped statements run for every requested uuid whatever the first statement returned, and the reply is the requested uuids. hard_delete_objects_per_call(object_type, object_uuid), the fallback (D15): for a type in the mapping, call each mapped public function for the uuid in order, thendelete_state,delete_object_metadataanddelete_object_events, which is the order today'shard_delete()uses. No batch call.- The gRPC wrapper goes through
_grpc_call(), so it is timed and retried like any other call; it returns None ongrpc.RpcError, except thatUNIMPLEMENTEDraises a newexceptions.HardDeleteBatchUnavailable, so the caller can fall back (D22). Followexceptions.CoalescingUnavailableand the comment above the coalescing wrappers (mariadb.py:22684), which is the precedent for an RPC-specific exception onUNIMPLEMENTED. - Servicer in
shakenfist/daemons/database/main.py, with its Monitor counter, validating the request the way the public function does.
Tests: unit tests for validation, routing and the gRPC wrapper's
error mapping, mirroring phase 2's test_mariadb_objects_by_state_page.py.
Live tests (SF_MARIADB_TEST_DSN, mirroring
test_mariadb_objects_by_state_page_live.py), which phase 2 ran
locally against a throwaway mariadb:11.8 container with PyMySQL (see
phase 2's execution notes for the setup):
- for each batchable type, rows created in every mapped table for three uuids, of which one is eligible, one is in a non-final state, and one is final but too young; only the eligible one's rows go, across every table, and the reply names only it;
- a uuid shared with another object type is not touched in that type's tables;
- unconditional mode deletes every mapped row of a non-final object,
a too-young object, and an object with no
object_statesrow; - a statement that fails part way (inject one by patching a builder) leaves every table as it was: the transaction rolled back;
- 1,001 uuids is refused.
Commit subject: "Add a batch hard delete RPC."
Brief 3c: pin the list to today's classes¶
This step reads the classes before 3d changes them. A unit test
module, shakenfist/tests/test_hard_delete_batch_parity.py.
For each type in 3b's mapping, build an instance of that type's class
without the database (look at how existing unit tests construct these
objects; most have a helper or accept a static-values dict), patch
every public mariadb.delete_*, mariadb.remove_all_references_from
and eventlog/add_event path the classes reach with recording
mocks, call the real hard_delete(), and assert:
- the ordered list of delete functions called, minus the base three, equals the mapping's list for that type;
- the base three were called;
- nothing outside the allowed set was called (a new side effect in a
class's
hard_delete()must fail this test, not pass silently); hard_delete_objects_per_call()for that type calls the same sequence, base three included, as the class'shard_delete().
Write each type's expected sequence into the test as a literal,
recorded from today's hard_delete(); 3d keeps those literals when it
moves the classes.
Also assert that every type in OBJECT_NAMES_TO_CLASSES is either in
the mapping or in an explicit per-object set (instance, namespace,
namespace_claim, node) in the same test module, so a new object
type cannot be silently left out of both.
Mutation-test it with the mutation-check skill: drop one function
from one type's mapping; add one delete call to one class's
hard_delete(); move a type from the mapping to nowhere. Each must
fail the right test. Report the script's path.
Commit subject: "Pin the batch hard delete to each class."
Brief 3d: move the batchable classes onto the batch¶
DatabaseBackedObject.hard_delete()(baseobject.py:759): for a type in 3b's mapping, callmariadb.hard_delete_objects(type, [uuid], unconditional=True); onHardDeleteBatchUnavailable, callmariadb.hard_delete_objects_per_call(type, uuid). Write the audit event only when the delete succeeded; a None reply logs an error and writes no event. Types not in the mapping keep today's three calls.DatabaseBackedObjectWithOperations.hard_delete()(baseobject.py:835): its targets delete is in the mapping forartifactandnetwork, so it runs only for a type not in the mapping, which today isinstance.- Remove the row deletes from every batchable class's override:
BaseOperation(operations/baseoperation.py:268, and move its comment's reasoning about delete order to the mapping, noting that one transaction makes the order invisible to readers),AgentOperation,Artifact,Network,Blob,DnsMasq,NetworkInterface,IPAM(keep the in-memory early return),NamespaceKey,TrustedIssuer,MappingRule,Upload. Remove an override that is left empty. - Do not touch
instance.py,namespace.py,namespace_claim.pyornode.py. - Convert 3c's test. For each batchable class it now asserts that the
real
hard_delete()makes exactly onehard_delete_objectscall, unconditional, with that type and uuid, one audit event, and no othermariadbcall; that onHardDeleteBatchUnavailablethe calls equal 3c's literal sequence; and that a None reply writes no audit event. Keep the classification test. - Update the existing unit tests that asserted a batchable class's individual deletes, including the namespace cascade's, to assert the batch call with the right type and uuid. Do not weaken an assertion to make it pass: each one that named a delete must still name what is deleted. Report every test file changed and why.
Mutation-test with the mutation-check skill: a batchable type takes
the old three-call path; the fallback is skipped; the audit event is
written on a None reply; Instance loses its targets delete; one
class keeps a now-duplicated row delete. Report the script's path.
Commit subject: "Hard delete through the batch RPC."
Brief 3e: batch the sweep¶
In shakenfist/daemons/cluster/scheduled_tasks.py:
_drain_deleted_object_work()(line 1139) takes turns as D17 says. A batchable type (one in 3b's mapping) pops up toconfig.DELETED_OBJECT_BATCH_SIZEuuids and callsmariadb.hard_delete_objects()withFINAL_OBJECT_STATESandtime.time() - _deleted_object_delay(objtype), insidemariadb.rpc_timing_scope('per_deleted_object', objtype). For each returned uuid,eventlog.add_event(EVENT_TYPE_AUDIT, objtype, uuid, 'hard deleted object')(D20). A per-object type pops up to the same number and runs the existing_hard_delete_deleted_object()on each, checking the deadline between them.- The pass stops at whichever comes first: the time budget, or
config.DELETED_OBJECT_SWEEP_MAX_OBJECTSentries taken (D18). A turn never takes more than the cap has left. - Outcomes and metrics as D21: each batch entry is observed in
CLUSTER_DELETED_OBJECT_SECONDSwith outcomehard_deletedor the newskipped, each with an equal share of the batch's wall time;CLUSTER_DELETED_OBJECT_BATCH_SECONDS(new, byobject_type) observes the call. AddskippedtoDELETED_OBJECT_OUTCOMES, so the pass log line gains the field. - Failure handling. A None reply (a failed call) counts the whole
batch
failedand drops it: the next walk of that type, after its recheck, finds them again.DatabaseUnavailableputs the batch back at the front of its type's work and ends the pass, as the fill does.HardDeleteBatchUnavailable(D22) runs that turn per object and logs a warning once per pass. - Correct the two "28 object types" comments (lines 1019 and 1052) to 29, and the test docstring that repeats it.
In shakenfist/config.py, beside DELETED_OBJECT_SWEEP_SECONDS:
DELETED_OBJECT_BATCH_SIZE: int = Field(25, ge=1, le=1000, ...) and
DELETED_OBJECT_SWEEP_MAX_OBJECTS: int = Field(100, ge=1, ...), with
descriptions an operator can act on.
Tests in shakenfist/tests/test_daemon_cluster_scheduled_tasks.py,
alongside phase 2's DeletedObjectSweepPagedWorkTestCase:
- a batchable type's turn makes one
hard_delete_objectscall with up to the batch size, nofrom_db(), and one audit event per returned uuid; - a per-object type's turn still goes through
from_db()andhard_delete(), one at a time; skippedandhard_deletedare counted from the reply;- the per-pass cap stops the pass, and a turn takes only what the cap has left;
- the rotation still serves a per-object type between batch turns (a
fairness test in the spirit of phase 2's
DeletedObjectSweepFairnessTestCase); - None,
DatabaseUnavailableandHardDeleteBatchUnavailableeach behave as above.
Mutation-test with the mutation-check skill (ignore the cap; batch a
per-object type; skip the audit event; drop the requeue on
DatabaseUnavailable) and report the script's path.
Commit subject: "Hard delete most types in batches."
Brief 3f: document the changes¶
docs/operator_guide/database.md, the deleted-object sweep section: bullets forDELETED_OBJECT_BATCH_SIZEandDELETED_OBJECT_SWEEP_MAX_OBJECTSbesideDELETED_OBJECT_SWEEP_SECONDS, saying the cap is the drain-rate knob and the time budget a ceiling; which types are deleted in batches and which one at a time, and why in one sentence; theskippedoutcome andcluster_deleted_object_batch_secondsin the metrics list; and that a pass now runs every minute.docs/release_notes/v07-v08.md: extend the deleted-object sweep entry.docs/developer_guide/database_internals.md: one paragraph on the batch RPC and its two modes, the mapping inmariadb.pyas the one place a batchable type's deletes are declared, the per-call fallback, and the parity test whose literal must change with it.
No plan, phase or decision citations outside docs/plans.
Commit subject: "Document batched hard deletes."
Brief 3g: measure (management session, after deploy)¶
Once sfcbr runs a build containing this phase
(cluster_deleted_object_batch_seconds_count exists), wait 24 hours
and query the 33fl Prometheus as phase 2's brief 2g did (the script
is kept in this phase's execution notes), adding:
# Batch latency and size, per type
sum by (object_type) (rate(cluster_deleted_object_batch_seconds_sum[24h]))
/ sum by (object_type) (rate(cluster_deleted_object_batch_seconds_count[24h]))
sum by (object_type) (increase(cluster_deleted_object_seconds_count{outcome=~"hard_deleted|skipped"}[24h]))
/ sum by (object_type) (increase(cluster_deleted_object_batch_seconds_count[24h]))
# What the tier paid, against the 24 hours before the deploy
sum(rate(database_requests_total[24h]))
sum(rate(mysql_global_status_commands_total{command="commit"}[24h]))
sort_desc(sum by (operation) (rate(database_requests_total{caller_daemon="cluster"}[24h])))
# Every daemon's hard deletes now go through the batch (D15)
sum by (caller_daemon) (rate(database_requests_total{operation="HardDeleteObjects"}[24h]))
The per-commit reading holds if a batch of n costs about one commit plus a small per-row term, rather than n times an operation's 135 ms. Phase 4's planning commit records the results under "What phase 3 measured", with the per-object cost per type against phases 1 and 2, and sets phase 4's drain rate from them.
Risks and mitigations¶
- The table list drifts from a class's
hard_delete(). A batch that misses a table leaks rows forever; one with an extra table deletes rows it should not. Mitigation: 3c's parity test, which fails on either, and its check that every type is classified. - The guard is wrong and deletes a live object. Mitigation: the guard is the first statement, and 3b's live test puts a non-final and a too-young object in the same batch as an eligible one and checks all of their tables.
- A batch's transaction is large or contends.
event_objectsis the widest table; a blob had 3.4 s of event-reference deletes in phase 1. Mitigation: the default batch is 25, the server refuses more than 1,000,_retry_transaction()retries a deadlock, and 3g reads batch latency per type. Blobs number in the tens. - Shaken Fist supports MariaDB 10.11, and sfcbr runs 11.8.
DELETE ... RETURNINGexists in both. CI's live lane (the "Schema ENUM widening" job) runs only in the merge queue, on Debian 13's 11.8, so no CI run covers 10.11. Mitigation: 3b's live tests run locally against throwaway 11.8 and 10.11 containers before merge, and the execution notes record both runs. - Snapshot isolation. MariaDB 11.6.2 and later can raise
ER_CHECKREADfor a transaction that reads before it writes. Mitigation: the transaction opens with the guardedDELETE, as the placement transactions do. - D18's default is too low or too high for some cluster. 100 a minute is 144k a day, against sfcbr's 40k arrivals. Mitigation: it is an option, documented as the knob, and phase 4 sets sfcbr's from measurement.
- The batch path's events are lost. The audit events go through the same spool as every other event, with its existing drop counter. Losing one loses an audit line, not data.
- Mixed versions during the deploy. Every daemon's
hard_delete()now needs the RPC. Mitigation: D15's per-call fallback, tested in 3d, and D22's for the sweep, tested in 3e. - A rewritten cascade test loses what it checked. Mitigation: 3d reports every test file it changes, and the management session reads each changed assertion against the one it replaced.
Definition of done¶
pre-commit run --all-filespasses.- 3a's cadence test fails with
every(1).minutesand passes with the change. - 3b's live tests pass against MariaDB 11.8 and 10.11 locally, including the rollback test.
- 3c's parity test fails for each of its three mutations, and every
type in
OBJECT_NAMES_TO_CLASSESis classified. - The per-object classes are untouched:
git diff $(git merge-base origin/develop HEAD) --stat -- shakenfist/instance.py shakenfist/namespace.py shakenfist/namespace_claim.py shakenfist/node.pyis empty. No batchable class deletes rows itself:grep -rn "def hard_delete" shakenfist --include=*.py | grep -v /tests/lists onlybaseobject.py(twice),ipam.py,instance.py,namespace.py,namespace_claim.pyandnode.py. - 3d's tests fail for each of its five mutations, and 3e's for each of its four.
tox -e genprotosleaves no diff.- The docs name both new options, the
skippedoutcome and the batch histogram, andgrep -rn "phase [0-9]\|D1[4-9]\|D2[0-3]" docs --include=*.md | grep -v docs/plansfinds nothing this phase added. - After deploy, 3g's numbers are recorded by phase 4's planning commit.
Execution notes¶
- Back brief, 2026-10-23 (UTC). Michael kept D18's default of 100
objects a pass. He chose to move the batchable classes'
hard_delete()onto the batch rather than keep a second copy of the table list, so D15 changed before any step ran: D14 gained the unconditional mode, a new step 3d moves the classes, and the later steps became 3e to 3g. The live tests may run against a throwaway localmariadb:11.8container. This session may push and open the pull request once the definition of done passes. - 3b, 2026-10-23 (UTC). The survey table said
agentoperationalso ran the three cluster operation deletes. It does not:AgentOperationderives fromBaseOperation, and the override atbaseoperation.py:268belongs toBaseClusterOperation. The table is corrected above. The batch also evicts the object cache entries the mapped public functions evict, a failed transaction is answeredABORTED(which_grpc_call()does not retry) rather thanUNAVAILABLE, and the client and servicer share one request check. Live tests ran against MariaDB 11.8.9 with snapshot isolation on; 12 mutants, all killed. - 3d, 2026-10-23 (UTC). An unconditional batch reply that omits
the object is treated as a failure, like None: an error is logged
and no audit event written.
MockMariaDBstands in forhard_delete_objects()in unconditional mode only, deleting through the per-call fallback and so through its existing doubles. The definition of done's item 5 compared againstorigin/develop, which has since gained two commits toinstance.py; it now compares against the merge base. - 3e, 2026-10-23 (UTC). After
HardDeleteBatchUnavailablethe rest of the pass, not only that turn, runs one object at a time, so the warning is logged once a pass; the next pass asks again. A batch call that raises anything else is counted like a None reply, and the batch histogram observes only calls that returned. Tests that drained network, blob, upload and namespace key objects throughfrom_db()now use the four per-object types, since those types are batched. - Definition of done, 2026-10-23 (UTC), before the push. Items 2,
4, 5, 6 and 7 pass: the cadence mutant and a mapping-drop parity
mutant were re-run by the management session and killed, the four
per-object classes are unchanged against the merge base, only the
expected
hard_deleteoverrides remain, 3d's 8 and 3e's 19 mutants were all killed, andtox -e genprotosleaves no diff. Item 8's grep as written matches 5,659 existing lines indocs/, so it cannot show what this phase added; run over the phase's added lines outsidedocs/plansit matches none, and the operator guide names both new options,skippedand the batch histogram. Item 3's 11.8 half passed in 3b; its 10.11 half is the pull request's CI run. Item 9 waits for deploy. - Review round 1, 2026-10-24 (UTC). Item 3 as first written
expected CI to run the live tests on 10.11. It cannot: the pull
request's sanity job skipped all eight for want of
SF_MARIADB_TEST_DSN, and the live lane runs only in the merge queue, on 11.8. The risk and item 3 are corrected above. The eight live tests then passed locally against a throwawaymariadb:10.11container (10.11.19), which completes item 3. The review also asked for three changes to the sweep's failure handling: the client now remembers anUNIMPLEMENTEDanswer for five minutes; a batch refused whole, such as one naming a malformed uuid, is retried that turn one object at a time; and a comment says whatskippedcan include after a retried call. Five mutants, all killed.
Back brief¶
Before starting, confirm with Michael:
- D18's default of 100 objects a pass. It holds the drain near today's rate on purpose. If he would rather phase 3 start the drain, the number to agree is the default, and 3g then measures load at that rate instead.
- D15, which planning wrote as leaving
hard_delete()alone and carrying two copies of each batchable type's table list. - Whether this session may push and open the pull request once the definition of done passes.
- The live tests run locally against a throwaway MariaDB container, as in phase 2.