Single-target remote DELETE routes emit no counter delta: mavenRemotePackageEvict and its five siblings discard the composers' amounts

What is wrong

Six single-target management-API DELETE route arms evict one cached npm or Maven row and emit no storage-accounting counter delta. Each arm calls a datastore composer that reports the amount a delta needs, and then discards that amount with _.

While no part-2 merge request is merged, an eviction through one of these six routes moves the row and moves no counter. repositories.artifacts_count then reads too high after a package or version eviction, and repositories.size_bytes reads too high after a file eviction. Both values stay high until a reconciliation pass recomputes the scope. Once the emit lands at all six arms, each committed eviction moves its own delta on the next drain tick of the buffered counter pipeline.

The bulk delete workers for the same two formats already take these amounts. So the same eviction moves a counter through the bulk route and moves none through the single-target route.

The six arms

Measured at 710c99957cc99358cf3f1f330fab054c2d892915.

Format Symbol Declared at Discards the amount at
Maven mavenRemotePackageEvict internal/managementapi/package_delete.go:174 internal/managementapi/package_delete.go:202
npm npmRemotePackageEvict internal/managementapi/package_delete.go:306 internal/managementapi/package_delete.go:330
Maven mavenRemoteVersionEvict internal/managementapi/version_delete.go:208 internal/managementapi/version_delete.go:236
npm npmRemoteVersionEvict internal/managementapi/version_delete.go:331 internal/managementapi/version_delete.go:355
Maven mavenRemoteFileEvict internal/managementapi/file_delete.go:266 internal/managementapi/file_delete.go:312
npm npmRemoteFileEvict internal/managementapi/file_delete.go:450 internal/managementapi/file_delete.go:492

The four package and file arms discard an amount. The two version arms discard applied, which gates the emit rather than sizing it, because a version mark hides exactly the row it names.

What each arm owes, and what it must not move

The delta set splits by level, not by format. npm and Maven owe the same amounts at the same three levels.

Level What the write does repositories.artifacts_count repositories.size_bytes namespace_statistics.components_count
Package UPDATE ... SET soft_deleted_at -N, where N is the count of live cached versions the mark hid Must not move Must not move
Version UPDATE ... SET soft_deleted_at -1 Must not move Must not move
File hard DELETE of the row and its attachment Must not move -S, and only when the transaction removed the repository's last reference to that blob Must not move

So the asymmetry for npm and Maven is not one asymmetry. At a mark, artifacts_count moves and size_bytes must not. At a file hard delete, size_bytes moves and artifacts_count must not.

This differs from the Container arm. The Container remote eviction hard-deletes no file row, so it owes Δartifacts alone, and #775 (closed) carries that table.

Evidence for the table

internal/managementapi/artifact_write.go declares the two shapes and writes them:

  • deleteCounterTargetTombstone at line 181 covers a package or version mark. Its doc states that it moves artifacts_count alone, and that the footprint counters settle at the purger's reap.
  • deleteCounterTargetFile at line 191 covers the Maven and npm file hard-deletes. Its doc states that no artifact count moves, and that repository bytes free exactly when the delete removed the repository's last reference to the blob.
  • emitDeleteCounters at line 244 writes those two arms, and it emits no namespace delta for either one.
  • deleteCounters.needsDispatch at line 225 drops a zero-amount emit before it spends a dispatch slot. So a still-referenced blob and an idempotent re-mark cost nothing.

The two bulk arms that !2003 (merged) delivered pass exactly these amounts, and their comments carry the reason:

  • BulkDeleteNpmWorker.evictRemotePackage at internal/managementapi/bulk_npm_worker_remote.go:391. Its comment reads: "components_count and size_bytes both stay put: the mark leaves every counted row for the reap, and the size recompute reads soft_deleted_at at no level."
  • BulkDeleteNpmWorker.evictRemoteVersion at internal/managementapi/bulk_npm_worker_remote.go:509.
  • BulkDeleteNpmWorker.evictRemoteFile at internal/managementapi/bulk_npm_worker_remote.go:589.
  • MavenBulkWorker.remoteOps at internal/managementapi/bulk_maven_worker.go:594 passes the same pair through its three write wrappers. Its comment reads: "components_count waits for the reap: its count is physical."

Where the amounts come from

The composers report them already, and their doc comments name the caller obligation:

  • MavenRemoteEvictor.EvictMavenRemotePackage at internal/datastore/maven_remote_eviction.go:57 — "applied and hiddenVersions feed the caller's storage-accounting emit, counted inside the mark's transaction so the purger cannot take the decrement."
  • NpmRemoteEvictor.EvictNpmRemotePackage at internal/datastore/npm_remote_eviction.go:65 — the same sentence.
  • MavenRemoteEvictor.DeleteMavenRemoteFile at internal/datastore/maven_remote_eviction.go:143 — "freedSizeBytes is the blob's size when this transaction removed the repository's last reference, probed over recomputeMavenRemoteFilesSizeStmt's row set."
  • NpmRemoteEvictor.DeleteNpmRemoteFile at internal/datastore/npm_remote_eviction.go:146 — the same shape, over the npm union.
  • MavenRemoteEvictor.EvictMavenRemoteVersion at internal/datastore/maven_remote_eviction.go:104 and NpmRemoteEvictor.EvictNpmRemoteVersion at internal/datastore/npm_remote_eviction.go:107 report applied alone, by the same reasoning.

No new dependency is needed at the route arms. All six already hold a CounterEmitter:

  • Deps.Counters at internal/managementapi/handler.go:538 reaches the two Maven arms that take Deps, at package_delete.go:174 and version_delete.go:208.
  • npmPackageDeleteStores.counters at internal/managementapi/package_delete.go:52.
  • versionDeleteStores.counters at internal/managementapi/version_delete.go:91.
  • fileDeleteStores.counters at internal/managementapi/file_delete.go:82, which serves the Maven and the npm file arm.

emitDeleteCountersDetached at internal/managementapi/artifact_write.go:300 is the helper the other single-target routes use.

Ordering the emit

The hosted arms fix the shape, and the remote arms take it. mavenHostedPackageDelete writes the 202 first, returns when the composer reports no applied mark, and emits after that (internal/managementapi/package_delete.go:156-171). A remote arm owes the same three steps.

A remote eviction emits no usage-data event at any level. This work adds none. MavenBulkWorker.remoteOps states the reason at internal/managementapi/bulk_maven_worker.go:642-644: an eviction removes a cached copy of an upstream's artifact, so no usage event is owed.

Relationship to #775 (closed) and !2003 (merged)

  • #775 (closed)#775 (closed). This is the parent record for the whole gap. Its Scope section gives the npm and Maven arms to !2003 (merged) and keeps the Container remote eviction arm for itself.
  • !2003 (merged)!2003 (merged). It widened the six datastore composers, added the two last-reference probes, and wired the two bulk delete workers. It merged on 2026-08-27, and it named itself part 1 of two.
  • This issue is that stated part 2. It takes the six single-target route arms and their tests, and nothing else.

!2003 (merged)'s merged description states the remainder:

This is part 1 of two

Part 1 widens the composers, the two probes, and the two bulk delete workers.

The six single-target remote DELETE routes still emit no counter at all:

  • internal/managementapi/package_delete.go:202 (Maven) and :330 (npm)
  • internal/managementapi/version_delete.go:236 (Maven) and :355 (npm)
  • internal/managementapi/file_delete.go:312 (Maven) and :492 (npm)

Each of those call sites takes the new return values and discards them with _. A run against the live service confirmed it. All six routes answered 202, the target rows changed, and no counter moved after two drain intervals.

Part 2 adds the emit at those six routes, with their tests. Part 2 cannot land first, because the composers report no amount until part 1 lands.

This branch also removes the last in-code pointer to the issue. git grep -- '#775' -- '*.go' returns nothing at the tip, against internal/managementapi/bulk_maven_worker.go:595 at the merge base. This section is therefore the only remaining pointer to the work part 2 owes.

The six line numbers that quote names are unchanged at 710c99957cc99358cf3f1f330fab054c2d892915.

One correction to #775 (closed), which this issue records rather than fixes. The "Merge order: this issue outlives !2003 (merged)" section of #775 (closed) says that what remains after !2003 (merged) merges is the Container arm alone. !2003 (merged)'s own merged description names the six single-target npm and Maven arms as remaining too. !2003 (merged) is the later document and it is specific about which call sites it did not touch, so this issue follows !2003 (merged). Issue #775 (closed) keeps the Container arm, and this issue keeps the six.

Documentation this work owes

  • docs/dev/storage-accounting.md lines 1872 and 1873 point at work item 775 and say that the Maven and npm remote eviction arms emit no Δartifacts. That sentence speaks for those arms as a whole. Once the six route arms emit, the page needs the narrower statement. !2003 (merged) states that this page belongs to part 2, and it did not change it.
  • docs/testing/e2e/maven.md:141 and docs/testing/e2e/npm.md:199 each carry the sentence "The single-target remote DELETE routes move no counter, and reconciliation is what corrects those rows." Both sentences become false when the emit lands, so both rows need the new outcome.
  • docs/testing/e2e/maven.md:196 promises one artifact_registry_artifact_deleted event per committed hosted delete, and states that the remote leg emits none at the bulk job and at the single-target routes alike. That row needs no change, because this work adds no usage event.

Tests this work owes

Model them on the two bulk tables !2003 (merged) added, narrowed to a route arm. At 710c99957cc99358cf3f1f330fab054c2d892915, internal/managementapi/artifact_delete_counters_test.go carries no remote arm, so every case below is new:

  1. An applied package eviction emits one repository delta (-N, 0), for each format.
  2. An applied version eviction emits one repository delta (-1, 0), for each format.
  3. A file eviction that removed a row emits (0, -S) on the repository's last reference to the blob, for each format.
  4. A still-referenced blob emits nothing, and not a zero-valued delta.
  5. An idempotent re-mark emits nothing, and a file eviction that found the row already gone emits nothing.
  6. No arm emits a namespace delta at any level.
  7. A mark emits no size delta, and a file eviction emits no artifact delta.
  8. A request that answers 404 moves no counter.

Cover all six arms. The two package arms and the two file arms carry different amounts from the two version arms, and each arm has its own route.

Related to #775 (closed)

This is a bot message 🤖 — /smurfit