Processing outside the API — designate — branch stable/2026.2
Branch
stable/2026.2@e3dd774db145— repository https://opendev.org/openstack/designate — analysed on 2026-10-29
Most of Designate's behaviour happens after the HTTP response. The API only records an intent in the database (status: PENDING). designate-worker then pushes the zone to the pool targets, sends DNS NOTIFY and polls the nameservers until they serve the expected SOA serial. designate-producer runs the periodic tasks that batch serial increments, retry stuck zones and purge deleted ones. designate-mdns serves the zones to the DNS servers by AXFR and receives NOTIFY for secondary zones. designate-sink turns Nova and Neutron notifications into managed records.
Summary
| Process | Trigger | Type | Purpose | Reads | Writes / emits | Defined at |
|---|---|---|---|---|---|---|
| central | RPC server central (≈ 70 methods, call) | AMQP RPC | every business operation of the API, the worker, the producer and the sink | DB (all tables) | DB, RPC casts to worker, notifications dns.* | designate/central/service.py:57 |
| central | update_status | AMQP RPC cast from worker | apply the outcome of a zone push/poll to zone and records (PENDING → ACTIVE/ERROR/deleted) | zones, records | zones, records; notification dns.zone.update | designate/central/service.py:2543 |
| central | update_service_status / delete_service_status | AMQP RPC cast (heartbeats, producer) | maintain service_statuses | service_statuses | service_statuses | designate/central/service.py:3136 |
| central | _import_zone | in-process thread started by create_zone_import | parse the zone file and create the zone | request body | zones, recordsets, records, zone_tasks | designate/central/service.py:2895 |
| worker | create_zone, update_zone, delete_zone | AMQP RPC cast (topic worker) | push the zone to every pool target, NOTIFY, poll the nameservers, report the status | pool config (via central), tsigkeys, zones | backend API / rndc, DNS NOTIFY, DNS SOA queries, RPC update_status | designate/worker/service.py:166 |
| worker | perform_zone_xfr | AMQP RPC cast | AXFR a SECONDARY zone from its masters and store it | masters over DNS | RPC update_zone to central | designate/worker/service.py:219 |
| worker | get_serial_number | AMQP RPC call | SOA serial of a SECONDARY zone on one master | DNS SOA query | — | designate/worker/service.py:230 |
| worker | recover_shard | AMQP RPC cast from producer | re-send zones in ERROR, or PENDING for longer than the maximum propagation time | zones | RPC create/update/delete_zone to worker | designate/worker/service.py:197 |
| worker | start_zone_export | AMQP RPC cast | decide how a zone export is served and complete the export task | recordsets count, quota | RPC update_zone_export | designate/worker/service.py:208 |
| producer | increment_serial | timer, [producer_task:increment_serial]/interval (5 s) | bump the serial of zones flagged increment_serial, then push them | zones (increment_serial = 1) | RPC increment_zone_serial, worker update_zone | designate/producer/tasks.py:281 |
| producer | delayed_notify | timer, [producer_task:delayed_notify]/interval (5 s) | push zones flagged delayed_notify | zones (delayed_notify = 1) | worker update_zone, central update_zone | designate/producer/tasks.py:229 |
| producer | worker_periodic_recovery | timer, [producer_task:worker_periodic_recovery]/interval (120 s) | ask the worker to recover the producer's shard range | — | worker recover_shard | designate/producer/tasks.py:326 |
| producer | zone_purge | timer, [producer_task:zone_purge]/interval (3600 s) | hard-delete zones soft-deleted for more than time_threshold (7 days) | zones (deleted != 0) | RPC purge_zones | designate/producer/tasks.py:97 |
| producer | periodic_secondary_refresh | timer, 3600 s | AXFR SECONDARY zones whose refresh interval elapsed | zones (type = SECONDARY) | RPC xfr_zone | designate/producer/tasks.py:188 |
| producer | periodic_exists | timer, 3600 s | billing/audit: one dns.zone.exists notification per zone | zones | notifications dns.domain.exists, dns.zone.exists | designate/producer/tasks.py:134 |
| producer | periodic_check_service_status | timer, 3600 s | mark services whose heartbeat is older than 1800 s DOWN | service_statuses | RPC update_service_status | designate/producer/tasks.py:344 |
| producer | periodic_cleanup_stopped_service_status | timer, 172800 s | delete STOPPED services, and DOWN ones older than 7 days | service_statuses | RPC delete_service_status | designate/producer/tasks.py:374 |
| producer | partition rebalance | tooz group join/leave event | split the 4096 zone shards between producer instances | tooz group members | — | designate/producer/service.py:68 |
| mdns | DNS QUERY (AXFR/IXFR, other types) | UDP/TCP DNS server, port 5354 | serve zone contents to the pool's DNS servers | zones, recordsets, records, tsigkeys, catalog zones | DNS responses | designate/mdns/handler.py:55 |
| mdns | DNS NOTIFY | UDP/TCP DNS server | trigger an AXFR of a SECONDARY zone when its master changed | zones, master SOA (DNS) | worker perform_zone_xfr | designate/mdns/handler.py:91 |
| sink | notification listener | AMQP notifications (nova, neutron exchanges, topic notifications) | create or delete A/AAAA managed records for instances and floating IPs | payload | RPC create_managed_records, delete_managed_records | designate/sink/service.py:112 |
| all | heartbeat | timer, [heartbeat_emitter]/heartbeat_interval (10 s) | report the service as UP (and STOPPED on shutdown) | — | RPC cast update_service_status | designate/heartbeat_emitter.py:98 |
| central, producer | coordination | tooz heartbeat + run_watchers_interval (10 s) | distributed locks (central), group membership (producer) | tooz backend | tooz backend | designate/coordination.py:62 |
central (RPC server, topic central)
RPC server
- Trigger:
messaging.Target(topic=CONF['service:central'].topic, server=CONF.host)(designate/service.py:107), version6.12(designate/central/service.py:58). The topic defaults tocentral(designate/conf/central.py:43). The server uses theDefaultRPCAccessPolicy, so every public method ofServiceis callable (designate/rpc.py:197). - Emitter:
designate/central/rpcapi.py:37(CentralAPI), used by the API, the worker, the producer, the sink anddesignate-manage. All methods arecallexceptupdate_status,update_service_statusanddelete_service_status, which arecast(designate/central/rpcapi.py:328,designate/central/rpcapi.py:438,designate/central/rpcapi.py:442). The full table is in interactions. - Concurrency:
@lock.synchronized_zone()serialises operations per zone through a tooz lock namedzone-<id>, orcreate-new-zonefor zone creation (designate/common/decorators/lock.py:75). Without[coordination]/backend_url, the lock is a locallockutilslock (designate/coordination.py:57), which does not protect against several central processes. A thread-local set avoids re-entering the same lock (designate/common/decorators/lock.py:94). - Transactions:
@transactionopens a write session and retries the whole method up to 150 times, 50 ms apart, onDBDeadlock, with deep copies of the arguments (designate/storage/__init__.py:49,designate/storage/__init__.py:107). - Notifications:
@notification.notify_type()queues one notification per decorated call and emits the queue when the outermost decorated method returns (designate/common/decorators/notification.py:36). Nested calls (for examplecreate_zone→update_zoneof sub-zones) are therefore emitted together, after the whole operation. - Error behaviour: expected Designate exceptions are re-raised as
ExpectedExceptionso that oslo.messaging does not log them, and the client gets the original class back throughallowed_remote_exmods(designate/common/decorators/rpc.py:37,designate/rpc.py:46).
update_status (cast from the worker)
- Trigger: RPC
cast update_status(zone_id, status, serial, action)(designate/central/rpcapi.py:328), sent by the worker'sUpdateStatustask (designate/worker/tasks/zone.py:869). - Purpose: record the outcome of a push to the nameservers: the zone and its records become
ACTIVE,ERROR, or are removed. - Flow (
designate/central/service.py:2543):- Lock the zone, read it (
designate/central/service.py:2552). - If the reported
actionis not the zone's current action, keep the zone as it is: a newer change superseded the reported one (designate/central/service.py:2553,designate/central/service.py:2559). - A zone being deleted that did not end in
ERRORis treated asNO_ZONE(designate/central/service.py:2554). - Apply the state machine
_update_zone_or_record_status(designate/central/service.py:2637):SUCCESSwithserial >= zone.serial, for aPENDINGorERRORzone:CREATE/UPDATE→NONE/ACTIVE;DELETE→NONE/DELETED;ERRORon aPENDINGzone withserial >= zone.serialorserial == 0: statusERROR;NO_ZONE:CREATE/UPDATE→CREATE/ERROR(the zone must be created again);DELETE→NONE/DELETED.
DELETEDzones are soft-deleted (deleted = id,deleted_at = now,designate/storage/sqlalchemy/base.py:547); other zones are updated (designate/central/service.py:2573).- Records of the zone in
PENDING(andERRORforSUCCESS) withserial <= <reported serial>get the same transition (designate/central/service.py:2598).
- Lock the zone, read it (
- Idempotency: replaying the same cast is harmless, the transitions only apply to
PENDING/ERRORrows. - Cost: one
SELECTof the zone, oneUPDATE, oneSELECTof the pending records and oneUPDATEper changed record. - Recorded cases:
_rewrite/cases-zone-status_stable-2026.2.json(see rewrite pack § 3).
Zone import thread
- Trigger:
create_zone_importstarts_import_zonein the service thread group and returnsPENDINGimmediately (designate/central/service.py:2895). - Flow: parse with dnspython (
relativize=False,check_origin=False), drop the apex SOA and NS recordsets, keep sub-zone NS, thencreate_zonewith the remaining recordsets (designate/central/service.py:2905,designate/central/service.py:2944). The task endsCOMPLETEwithzone_id, orERRORwith a fixed message (see errors). - Error behaviour: the import is lost if central stops before the thread finishes: the task stays
PENDINGforever. Nothing retries it.
worker (RPC server, topic worker)
create_zone / update_zone / delete_zone
- Trigger: RPC
caston topicworker(designate/conf/worker.py:43), version1.2(designate/worker/rpcapi.py:43). Emitted by central after every zone or recordset change (designate/central/service.py:936,designate/central/service.py:1175,designate/central/service.py:1487), by the producer (designate/producer/tasks.py:270,designate/producer/tasks.py:323) and by the worker itself on recovery (designate/worker/tasks/zone.py:937). - Purpose: make the pool's DNS servers serve the zone at its current serial (or stop serving it).
- Data read: the pool, lazily loaded through central
get_pooland cached for the life of the process (designate/worker/service.py:133). A pool change is therefore only seen after a worker restart [inferred]. A pool without targets is re-read every 5 s until it has some (designate/worker/service.py:90). - Flow (
designate/worker/service.py:148,designate/worker/tasks/zone.py:347):ZoneActor: for every pool target, in parallel ([service:worker]/threads, 200), call the backend driver (create_zone,update_zoneordelete_zone) and send a NOTIFY to the target'shost:portoption (designate/worker/tasks/zone.py:100). Each target is retriedpoll_max_retries(10) times,poll_retry_interval(15 s) apart (designate/worker/tasks/zone.py:165). When the pool has a catalog zone, the backend is not called: only a NOTIFY of the catalog zone is sent for creations and deletions, the secondaries discover the member zone from it (designate/worker/tasks/zone.py:120).- If fewer than
threshold_percentage(100 %) of the targets succeeded, set the zoneERRORthroughupdate_statusand stop (designate/worker/tasks/zone.py:283). - Wait
poll_delay(5 s) (designate/worker/tasks/zone.py:331). ZonePoller: query the SOA of the zone on every pool nameserver (designate/worker/tasks/zone.py:543), with the nameserver's TSIG key when it has one. A serial>= zone.serialcounts as positive; for a deletion, an absent zone (serial == 0) counts as positive (designate/worker/tasks/zone.py:386). Retry up topoll_max_retriestimes, stopping early if the zone's action changed meanwhile (designate/worker/tasks/zone.py:591).- Report
SUCCESSwith the lowest serial seen,ERROR, orNO_ZONEwhen too many nameservers do not have the zone (designate/worker/tasks/zone.py:633), throughupdate_status. - Send a NOTIFY to every
also_notifiesentry of the pool (designate/worker/service.py:155).
- Concurrency: none between workers: two workers may push the same zone at once (central casts without a server). The serial comparison and the
actioncheck inupdate_statusmake the last outcome win. - Error behaviour: backend exceptions are logged per attempt (
designate/worker/tasks/zone.py:150); a missing TSIG key skips the target or the poll (designate/worker/tasks/zone.py:84,designate/worker/tasks/zone.py:671). Timeouts on SOA queries are logged and count as "no answer" (designate/worker/tasks/zone.py:482). - Cost: per zone change,
targets × (backend call + NOTIFY)then up topoll_max_retries × nameserversSOA queries. The worst case beforeERRORis the maximum propagation timepoll_timeout × poll_max_retries + poll_max_retries × poll_retry_interval + poll_delay= 455 s with the defaults (designate/utils.py:354). - Recorded cases:
_rewrite/cases-parse-query-results_stable-2026.2.json.
perform_zone_xfr / get_serial_number (secondary zones)
- Trigger: cast
perform_zone_xfrfrom central when a SECONDARY zone is created or its masters change (designate/central/service.py:939,designate/central/service.py:1098), from mdns on NOTIFY (designate/mdns/handler.py:183), fromxfr_zone(designate/central/service.py:1235).get_serial_numberis acallfrom centralxfr_zone(designate/central/service.py:1227). - Flow:
ZoneXfrdoes an AXFR from the masters in random order, every resolved address of each master, with[service:worker]/xfr_timeout(10 s) (designate/dnsutils.py:152), rebuilds zone and recordsets from the answer (designate/dnsutils.py:87), setstransferred_atand calls centralupdate_zone(increment_serial=False)(designate/worker/tasks/zone.py:231).GetZoneSerialqueries the SOA up toserial_max_retries(3) times,serial_retry_delay(1 s) apart, withserial_timeout(1 s) (designate/worker/tasks/zone.py:710). - Error behaviour:
XFRFailurewhen no master answered, logged as a warning, the zone is left unchanged (designate/worker/tasks/zone.py:241).
recover_shard
- Trigger: cast from the producer's
worker_periodic_recoverywith the producer's shard range (designate/producer/tasks.py:341). - Data read:
SELECT … FROM zones WHERE shard BETWEEN <begin> AND <end> AND status = 'ERROR', plus zonesPENDINGwhoseserialis older thannow - max_prop_time(designate/worker/tasks/zone.py:896). - Flow: re-cast
create_zone,update_zoneordelete_zoneaccording to the zone's action (designate/worker/tasks/zone.py:937). - Cost: two queries per producer instance every 120 s; zones in
ERRORare retried forever, every 120 s.
start_zone_export
- Flow: count the recordsets; with
[service:worker]/export_synchronous(default true), check theapi_export_sizequota and set the taskCOMPLETEwithlocation = designate://v2/zones/tasks/exports/<id>/export, elseERROR(designate/worker/tasks/zone.py:965). The file itself is rendered on demand byGET …/export(designate/api/v2/controllers/zones/tasks/exports.py:41).
producer (timers, sharded)
All tasks are stevedore plugins of designate.producer_tasks; [service:producer]/enabled_tasks unset means every task (designate/plugin.py:91). Each task runs every [producer_task:<name>]/interval seconds in the service thread group, with stop_on_exception=False (designate/producer/service.py:93). Zones have a shard = first three hex digits of their id (0–4095, designate/storage/sqlalchemy/tables.py:56). With a tooz backend, producer instances join the producer group and each gets ceil(4096 / members) consecutive shards, recomputed on every join or leave (designate/coordination.py:172); without a backend, a single producer owns all shards (designate/coordination.py:216). Queries are paged by per_page (100) through central find_zones with a marker (designate/producer/tasks.py:78).
increment_serial
- Data read:
zones WHERE shard BETWEEN … AND increment_serial = 1 ORDER BY updated_at ASC LIMIT batch_size(designate/producer/tasks.py:291). - Flow: for each zone not being deleted, central
increment_zone_serialsetsserial = <Unix time>andincrement_serial = 0, and rewrites the SOA record (designate/central/service.py:839,designate/storage/sqlalchemy/__init__.py:625); unless the zone also waits for a delayed NOTIFY, the zone is setUPDATE/PENDINGand pushed (designate/producer/tasks.py:318). - Why: API changes only set the flag (
designate/central/service.py:1109); the serial is incremented at most once per interval per zone, so a burst of recordset changes results in one push. - Ordering: the serial is the current Unix time, so two increments within the same second give the same serial [inferred]; the poller accepts
>=.
delayed_notify
- Data read:
zones WHERE shard BETWEEN … AND delayed_notify = 1 AND increment_serial = 0 ORDER BY updated_at ASC LIMIT batch_size(designate/producer/tasks.py:247). - Flow: set
UPDATE/PENDINGif the zone was idle, cast workerupdate_zone, clear the flag through centralupdate_zone(designate/producer/tasks.py:258). Set by pool NS changes (designate/central/service.py:516).
zone_purge
- Data read:
zones WHERE shard BETWEEN … AND deleted != '0' AND deleted_at <= now - time_threshold,LIMIT batch_size(designate/producer/tasks.py:120). - Flow: central
purge_zoneshard-deletes them, re-parenting their child zones first (designate/storage/sqlalchemy/__init__.py:675).
periodic_secondary_refresh
- Flow: for SECONDARY zones already transferred, call central
xfr_zonewhennow - transferred_at > zone.refresh(designate/producer/tasks.py:208).xfr_zonecompares one master's serial with the stored one and casts an AXFR when it is higher (designate/central/service.py:1226). - Note: the run interval (3600 s) bounds the effective refresh: a zone with
refresh = 600is still checked at most hourly.
periodic_exists, service status tasks
periodic_existsemitsdns.domain.existsanddns.zone.existswithaudit_period_beginning/endingfor every zone of the shards (designate/producer/tasks.py:169).periodic_check_service_statusmarksUPservices whoseheartbeated_atis older thantime_threshold(1800 s)DOWN(designate/producer/tasks.py:360). With a 3600 s interval, a dead service may stayUPfor up to 90 minutes.periodic_cleanup_stopped_service_statusdeletesSTOPPEDrows and, whentime_threshold > 0,DOWNrows older than it (designate/producer/tasks.py:382).
mdns (DNS server)
- Trigger: UDP and TCP sockets on
[service:mdns]/listen(default0.0.0.0:5354,designate/conf/mdns.py:29), one thread per UDP query and per TCP connection (designate/service.py:177,designate/service.py:330). TCP messages are length-prefixed (RFC 1035 § 4.2.2) and pipelined queries are handled sequentially (designate/service.py:260). - Middleware:
SerializationMiddlewaredecodes the wire message with a keyring backed by thetsigkeystable (designate/dnsmiddleware.py:68,designate/dnsutils.py:40); messages with an unknown TSIG key or a bad signature are dropped without answer (designate/dnsmiddleware.py:94,designate/dnsmiddleware.py:128).TsigInfoMiddlewareattaches the key to the request (designate/dnsmiddleware.py:155). - QUERY AXFR/IXFR (
designate/mdns/handler.py:241): IXFR is answered with a full AXFR. The zone is looked up by name, scoped by the TSIG key (POOLscope → that pool,ZONEscope → that zone) or, unsigned, the default pool;query_enforce_tsigrefuses unsigned queries (designate/mdns/handler.py:190). Records are streamed as SOA, all other recordsets, SOA (designate/mdns/handler.py:266), split into several messages when they exceedmax_message_size(65535), each signed with TSIG when the request was (designate/mdns/handler.py:487). A catalog zone is generated on the fly from the pool's zones (designate/mdns/handler.py:279). Unknown zone or forbidden →REFUSED. - QUERY other types (
designate/mdns/handler.py:371): authoritative answer from the database (ancestor walk to find the zone, same pool scoping); every miss isREFUSED, neverNXDOMAIN(designate/mdns/handler.py:399). - NOTIFY (
designate/mdns/handler.py:91): find SECONDARY zones with the notified name in every pool, keep the one listing the sender as master, query the sender's SOA and castperform_zone_xfrwhen the serial differs (designate/mdns/handler.py:163). Unknown zone →NOTAUTH; sender not a master →REFUSED. - Cost: an AXFR reads all recordsets of the zone with one query (
find_recordsets_axfr); every DNS server of every pool AXFRs every changed zone.
sink (notification listener)
- Trigger: oslo.messaging notification listener on one target per handler: exchange
[handler:<name>]/control_exchange(novaorneutron), topicsnotification_topics(notifications), pool[service:sink]/listener_pool_name(designate/sink/service.py:85,designate/conf/sink.py:62). Only handlers named in[service:sink]/enabled_notification_handlersare loaded (default none,designate/conf/sink.py:43). - nova_fixed (
designate/notification_handler/nova.py:26):compute.instance.create.end→ one A/AAAA managed record per fixed IP, name built fromformatv4/formatv6(default%(hostname)s.%(zone)s), in the zone[handler:nova_fixed]/zone_id;compute.instance.delete.start→ delete the managed records ofinstance_id. - neutron_floatingip (
designate/notification_handler/neutron.py:26):floatingip.update.endwith afixed_ip_address→ A record for the floating IP address; without it, or onfloatingip.delete.start→ delete. - Flow:
create_managed_recordscreates or replaces the recordset with records carryingmanaged_plugin_name,managed_resource_typeandmanaged_resource_id(designate/notification_handler/base.py:161,designate/central/service.py:2297); deletion removes them by those keys (designate/notification_handler/base.py:193). - Error behaviour: exceptions propagate to oslo.messaging (the message is requeued or dropped depending on the driver) [inferred]; a missing
zone_idis logged and the event ignored (designate/notification_handler/nova.py:53). Invalid IPv6 addresses are skipped (designate/notification_handler/base.py:135). - Formats: the octets of the address are available as
%(octet0)s… and%(ip_address)s(dots or colons replaced by dashes) (designate/notification_handler/base.py:70).
Heartbeats and coordination
- Heartbeat (
designate/heartbeat_emitter.py:53): everyheartbeat_interval(10 s), each service castsupdate_service_status(ServiceStatus(service_name, hostname, status=UP, stats, capabilities, heartbeated_at))to central (designate/heartbeat_emitter.py:129); on stop it sendsSTOPPED(designate/heartbeat_emitter.py:73).GET /v2/service_statusesreads this table. - tooz: central and producer start a coordinator when
[coordination]/backend_urlis set (designate/coordination.py:75); the producer also creates/joins theproducergroup and runs the watchers everyrun_watchers_interval(10 s) (designate/coordination.py:122). Without a backend there is no cross-process zone lock and no shard split: running two central or two producer processes without a backend is unsafe [inferred].
Source: reverse-spec-designate/05-async-processing_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.