Repository navigation
Fixes #755: Evict deleted Custom Object Types' models from other workers - #756
Conversation
Deleting a Custom Object Type unregistered its generated model only in the worker that handled the delete. Other workers kept it registered, so it stayed in other models' _meta.related_objects, and a later delete of a tag, owner or related object there (core's handle_deleted_object) queried the dropped table. On a committed delete outside a branch, write a fresh token to NetBox's shared cache. At the start of every request and job, each worker compares it with the last token it saw and, when it changed, unregisters every generated model whose type no longer exists. The unregister step moves out of CustomObjectType.delete() into a reusable unregister_model(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@claude /review and report only on actionable issues. |
|
Claude finished @bctiemann's task in 2m 56s —— View job Code Review
The approach is sound: a single shared-cache token triggers a per-worker eviction check on every request/job start, costing one cache read in the steady state. Two actionable issues and one minor note below. 1. Test isolation —
|
|
Checked all three; no changes needed:
🤖 Generated with Claude Code |
jnovinger
left a comment
There was a problem hiding this comment.
With the shipped gunicorn config (threads = 3), can a request in one thread evict a type another thread in the same worker just created but hasn't committed yet, since the id snapshot at stale_models.py:66 won't see that row?
Separately, CustomObjectTypeField.delete() still deregisters a dropped multi-object through model only in its own process, so is that worth a follow-up issue for the same cross-worker case?
A type another thread has created but not yet committed is also missing from the database, so evicting every registered type without a row could unregister it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@jnovinger Thanks, both good catches. Threads evicting an uncommitted type: yes, that could happen. Fixed in fb30e88: a delete now marks its type's ID as deleted in the shared cache, and eviction unregisters only marked types. A type another thread has created but not committed isn't marked, so it stays registered even though this thread can't see its row ( (Before the fix, the impact was limited: the evicted type ended up in the same "not yet registered" state it's in on every other worker after a create, and the next Multi-object field deletes: agreed, that's the same bug for through models. Workers regenerate the type's model when its |
Fixes: #755
Deleting a Custom Object Type unregistered its generated model only in the worker process that handled the delete. Every other worker kept it registered, so it stayed in other models'
_meta.related_objects. A later delete of a tag, an owner, or anything the type's fields pointed at then made core'shandle_deleted_objectquery the dropped table:relation "custom_objects_N" does not exist. This persisted until NetBox was restarted.Changed types don't have this problem, because
get_model()notices a newcache_timestampthe next time it's called. A deleted type's model is never looked up again, though, so nothing evicted it.Change
This uses NetBox's shared cache, the same way the live GraphQL schema signature already propagates type changes across workers (
graphql/live.py).CustomObjectType.delete(), outside a branch, marks the type's ID as deleted in the shared cache and writes a fresh random token there. It does this intransaction.on_commit, so other workers only act once the delete is visible to them. A token rather than a counter, so a cache flush or restart can't make a later delete look already seen.evict_deleted_custom_object_types()(newstale_models.py) runs on Django'srequest_startedand core'sjob_start(both available back to NetBox 4.5.2).table<id>models (oneget_many). It unregisters each marked model, along with its through models, then clears the registry caches.request_startedfires before any middleware, so no netbox-branching branch is active and the existence check reads main, where the models registered inapps.all_modelsbelong.delete()in main and signals then.CustomObjectType.delete()into a reusableCustomObjectType.unregister_model(), with the same behavior: under_global_lock, thenapps.clear_cache().Testing
New
tests/test_stale_models.py:test_tag_delete_succeeds_after_type_deleted_in_another_worker: recreates the bug in one process. It deletes a type, re-registers its stale model as a worker that didn't handle the delete would still have it, signals, then deletes a tag through the API.ProgrammingError: relation "custom_objects_<id>" does not exist.test_eviction_keeps_existing_types: only the deleted type's model is evicted, even if an existing type's ID is marked.test_eviction_keeps_unmarked_types_missing_from_the_database: a type that isn't marked stays registered when its row isn't visible, as with an uncommitted create in another thread.test_deleting_a_type_signals_other_workers: a committed delete marks the type and writes a new token.test_check_is_free_when_nothing_was_deleted: no queries when the token is unchanged.End to end, against NetBox 4.7.1 under gunicorn with 4 workers:
The full plugin suite passes: 1384 tests without netbox-branching and 1455 with it, 8 skipped in each. The end-to-end results above predate the change to per-type marks.
ruff checkis clean.Release note
Fixed deletes (e.g. of tags) failing with
relation "custom_objects_N" does not existon multi-worker deployments after a Custom Object Type was deleted (#755).🤖 Generated with Claude Code