PVAccess PV Name Resolution
This document specifies the PV name resolution (search) algorithm as implemented by PVXS, in enough detail to be re-implemented compatibly in another language. It is written for EPICS developers implementing or auditing a PVAccess peer.
Key words “MUST”, “MUST NOT”, “SHOULD”, “SHOULD NOT”, and “MAY”
are used as in RFC 2119.
Statements marked [protocol] are required for interoperability
with other PVA implementations.
Statements marked [policy] describe the behavior PVXS chose;
another peer MAY choose differently without breaking the protocol,
but the scaling properties in Scaling depend on them.
Statements marked [contract] are promises of the PVXS API
that application and Source authors are entitled to rely on.
All constants cited are collected in Constants.
1. Model
Name resolution answers one question: Which server claims to provide this PV name? It is a distinct phase from data transfer. Its output is a TCP endpoint plus a server identity (GUID), after which operations proceed over TCP. In a secure implementation, TCP may be wrapped in TLS. In the following, TCP is used to represent both TCP and TLS.
The unit of resolution is the Channel: one PV name within one client, identified by a CID (32-bit client-chosen ID). Resolution is many-to-one with respect to application requests — every operation on the same name shares one Channel and therefore one search sequence. Search traffic is proportional to the number of distinct unresolved names, never to the number of operations.
Three properties define the contract; every other rule follows from them.
Resolution is unbounded. A search repeats indefinitely until some server claims the name or the application drops all interest. There is no “PV not found” error and no negative caching. A name that does not exist yet resolves as soon as it appears.
Resolution is unreliable but self-healing. Searches and replies are datagrams and MAY be lost. Correctness never depends on any single message arriving — only on retries continuing.
Resolution is first-reply-wins. The first positive reply resolves the Channel. There is no election, ranking, or failover among servers claiming the same name.
2. Wire messages
Four messages participate. All carry the standard 8-byte PVA header (magic, version, flags, command, payload length); multi-byte fields use the byte order indicated by the header flags.
CMD_SEARCH (0x03) — client to server, UDP or TCP
Field |
Content |
|---|---|
|
uint32. [policy] PVXS always sends the constant |
|
uint8: |
reserved |
3 bytes, zero. |
|
16 bytes, IPv6 or IPv4-mapped. All-zero means “reply to the source address of this datagram”. PVXS sends all-zero over UDP. For TCP, the reply address is ignored. If the PVA server sends a CMD_SEARCH_RESPONSE, it will do so via the TCP connection where it received the CMD_SEARCH. |
|
uint16 UDP port on which the client expects replies. Zero over TCP. |
|
Size-prefixed list of strings. PVXS sends |
|
uint16 number of names following. |
|
|
CMD_SEARCH_RESPONSE (0x04) — server to client, UDP or TCP
Field |
Content |
|---|---|
|
12 bytes. Stable identity of the responding server instance. |
|
uint32. [protocol] MUST echo the value from the request. |
|
16 bytes. All-zero means “the source address of this reply”. |
|
uint16 TCP port. Over TCP, zero means “the port of this connection”. |
|
String, |
|
uint8 boolean. |
|
uint16, followed by |
CMD_BEACON (0x00) — server to client, UDP
Beacons announce existence and carry no PV names.
Field |
Content |
|---|---|
|
12 bytes. Stable identity of the announcing server instance. |
|
uint8, historically named QoS, undefined. PVXS sends zero and ignores the field on receipt. |
|
uint8. Incremented per beacon sent. PVXS ignores it on receipt. |
|
uint16. [policy] PVXS increments it whenever the set of registered Sources changes (§4), but ignores it on receipt: a restarted server is detected from a changed GUID or protocol version, not from this field. |
|
16 bytes, IPv6 or IPv4-mapped. All-zero means “the source address of this datagram”. PVXS sends all-zero. |
|
uint16 TCP port. |
|
String, |
server status |
Optional value. PVXS writes the NULL type code |
CMD_ORIGIN_TAG (0x16) — server to server, UDP over loopback
Prepended to a forwarded CMD_SEARCH (§6):
the optional CMD_ORIGIN_TAG starts the UDP packet,
followed by the forwarded CMD_SEARCH within the same UDP packet.
Field |
Content |
|---|---|
|
16 bytes, IPv6 or IPv4-mapped. The destination address of the original search datagram, before forwarding. |
3. Client algorithm
3.1 Channel cache
A client-side cache is optional. In the PVXS client, an in-memory cache is implemented. Channels are cached by (name, forced-server). A request for a cached name reuses the existing Channel and creates no new search state.
[contract] An unreferenced Channel is discarded after two consecutive sweeps of a 10s garbage-collection timer, so search traffic for an abandoned name stops within 10–20s of the last reference being released. After that, a new client request will create a new search state and restart search traffic.
The CID is assigned at Channel creation
and is stable for the Channel’s cache lifetime.
[policy] PVXS uses the CID as the sole search correlation key
and sends a constant searchSequenceID.
Two consequences a re-implementation MUST respect:
A reply MAY arrive at any time, over UDP or over any name-server TCP connection, correlated only by CID. It need not correspond to any recently sent request.
An implementation MUST NOT require replies to carry a per-request sequence number, and MUST NOT discard a reply because its
searchSequenceIDis unrecognized.
A Channel is in one of four states:
Searching,
Connecting (TCP being established),
Creating (CMD_CREATE_CHANNEL sent, reply pending),
or Active.
Only Searching Channels are searched.
3.2 The search ring
Retry scheduling is a timer wheel, not a per-Channel timer. This is the core of the design and the reason it scales.
[policy] The client keeps a ring of 30 buckets — each a list of Channels —
plus a cursor.
A repeating timer fires every 1s.
On each tick the bucket under the cursor is emptied,
every Searching Channel it held is packed into outgoing search messages,
and each is reinserted into the bucket n positions ahead,
where n is that Channel’s search attempt count,
incremented once per attempt and clamped to the ring size.
Channels no longer Searching (or destroyed —
the ring holds weak references) are dropped as the bucket drains;
there is no separate removal step.
Because reinsertion is n buckets ahead and exactly one bucket is drained per second, the per-Channel retry interval is n seconds and grows by one second per attempt until it saturates at one full revolution:
Attempt |
Delay since previous |
Elapsed |
|---|---|---|
1 |
10ms (batch coalescing) |
~0 |
2 |
0–1s |
~1s |
3 |
1s |
~2s |
4 |
2s |
~4s |
5 |
3s |
~7s |
… |
… |
… |
31 |
29s |
~436s |
32 and later |
30s |
one search per 30s, indefinitely |
A Channel is therefore searched ~12 times in its first minute, 31 times in its first ~7.3 minutes, and twice per minute thereafter. This is linear (arithmetic) backoff, not exponential: it reacts quickly to a server that is merely slow to start, while bounding steady-state broadcast load.
Two refinements matter for large clients:
Initial coalescing. A newly created Channel goes not into the ring but into a separate initial list, and a one-shot 10ms timer is armed. Every Channel created within that window is searched together in shared packets. [policy] This is what makes a tight application loop creating thousands of operations emit full packets instead of one packet per name.
Cohort splitting. Channels created together advance in lockstep and would pile into a single bucket. On reinsertion, if the target bucket holds more than 100 entries more than the following bucket, the Channel is deferred by one extra tick. This bleeds oversized cohorts into neighbouring buckets and smooths per-tick bursts.
3.3 Packet construction
Names from one drained bucket are packed into as many datagrams as needed.
[policy] A search datagram is limited to 1400 bytes total, chosen to stay under a 1500-byte MTU including Ethernet/IP/UDP headers — IP fragmentation of broadcast traffic is expensive for every host on the subnet.
Fixed overhead is 41 bytes
(8 header
+ 4 sequence
+ 4 flags/reserved
+ 16 reply address
+ 2 reply port
+ 5 protocol list
+ 2 count).
Each name costs 5 + strlen(name) bytes
(4 CID + 1 size prefix, for names shorter than 254 bytes).
Capacity is therefore floor(1359 / (5 + L)) names per datagram
for mean name length L — 54 names at L=20, 38 at L=30, 30 at L=40.
A name that does not fit in the current datagram is deferred to the next. A single name too long to fit an empty datagram is sent anyway and MAY fragment; a name too long for the 64 KiB buffer is dropped with an error log.
The initial pvAccessCPP implementation limits channel names to 500 chars in both the client and server code.
Every constructed datagram is sent to every configured destination and appended to every ready name-server TCP connection. Destination count is a direct multiplier on egress traffic.
3.4 Expedited search
Two events shorten latency without altering the ring’s structure.
Both use one mechanism: the tick interval switches to 200ms
for exactly one revolution (30 ticks, ~6s),
during which drained Channels are reinserted into the same bucket
and their attempt counts are not advanced.
Every Searching Channel is thus searched once more within ~6s,
and each Channel’s position in the backoff schedule is preserved.
Beacon-triggered.
A CMD_BEACON from a previously unseen (address, protocol) pair,
or one reporting a changed GUID or protocol version for a known pair,
means a server started or restarted.
Beacons are tracked with a 20000-entry cap —
a server in a restart loop cannot evict tracking of other servers —
and entries expire after 360s under a 180s timer.
Application-triggered.
Context::hurryUp().
[contract] It is advisory and idempotent:
it changes only latency, never the outcome,
and is ignored if called more than once per 30s
or while a fast revolution is already in progress.
An application MAY call it after submitting a batch of operations;
an implementation MUST NOT require it for correctness.
3.6 Failure and re-entry into search
A claim is a promise about the future, so it can be broken.
Re-entry into Searching is damped by where in the ring
the Channel is placed:
Event |
Reinserted at |
Effective delay |
|---|---|---|
Channel closed / TCP lost while |
cursor |
next tick |
Transport failure before channel create |
cursor + 10 |
~10s |
|
cursor − 1 |
~30s |
In all three cases the attempt count was reset when the name last resolved, so backoff restarts from the beginning. The last row is the important one: a server that claims a name and then refuses to create it would otherwise drive a tight search/create/refuse loop, so such a Channel is placed in the bucket that will be drained last.
Channels created with an explicit server address bypass search entirely — they connect directly, never enter the ring, and on refusal are retried only when the connection is re-established.
4. Server algorithm
A server holds no per-search state.
For each received CMD_SEARCH:
Drop it if the source address matches the configured ignore list (a listed entry with port 0 matches any port from that address).
Present all names in the message to every registered
Source, in Source order, as a single search operation. Each Source MAY claim any subset.If no name was claimed and the MustReply flag is clear, send nothing. [protocol] If MustReply is set, reply even with
found=falseand zero names —pvlist-style tools depend on this.Otherwise reply with the server GUID, the echoed
searchSequenceID, an all-zero address, the server’s TCP port,"tcp" or "tls",found=true, and the claimed CIDs.
[protocol] A UDP reply MUST be sent to the client’s declared reply address and port, transmitted through the interface the request arrived on, and sourced from the address the request was sent to. On a multi-homed host, replying from an arbitrary local address makes replies unusable, because clients match on the endpoint they can reach. A TCP reply always returns on the same connection.
The Source::onSearch() contract:
[contract] A Source MUST claim a name only if it is prepared to accept
onCreate()for that name immediately. If it cannot, it MUST stay silent and let the client retry — silence costs one retry interval, a broken claim costs ~30s (§3.6). For example, a gateway may react to a search request by attempting to resolve the name, but it must only claim the name once it is able to successfully proxy the channel.[contract]
onCreate()MAY be called for a name that was never claimed, and a claimed name MAY never be created. Search and create are independent.[contract]
onSearch()is invoked on the UDP receive worker, under a read lock on the Source list, for every search reaching the host. It MUST NOT block and MUST NOT do I/O. On a busy subnet it is among the hottest paths in the process; treat it as a hash-table lookup.Names are presented as NUL-terminated pointers into the receive buffer, valid only for the duration of the call.
[policy] Beacons are sent to the configured beacon destinations every 15s for the first 10, then every 180s. The change-count field is incremented whenever the set of registered Sources changes. It is emitted only for the benefit of peers that use it; the PVXS client ignores it and detects a restarted server from a changed GUID or protocol version (§3.4).
5. Multiple servers per host: unicast forwarding
Broadcast reaches every server on a subnet, but a unicast search reaches only one process per host — yet several PVA servers commonly share a host, all bound to the same UDP port, with only one receiving any given unicast datagram. PVA solves this with loopback multicast forwarding.
[protocol] A client sets the Unicast flag when, and only when, the destination is a genuine unicast address: not multicast, and not a local interface’s broadcast address. A server receiving a search whose destination is one of its own interface addresses:
rewrites the body’s reply address to the resolved client reply endpoint — the forwarded copy has lost the original UDP source address, so the recipient must trust the body;
clears the Unicast flag, so the message is not forwarded again;
prepends a
CMD_ORIGIN_TAGcarrying the original destination address; andsends the result to
224.0.0.128with TTL 1 via127.0.0.1, assuming IPv4. Use [ff02::42:1],1@::1 for IPv6.
Every PVA server joins that IPv4 or IPv6 multicast group on loopback, so all servers on the host see the search and reply directly to the client.
The receiving side accepts an origin tag only when the datagram arrived on loopback addressed to that multicast group, accepts at most one tag per datagram, refuses to forward an already-forwarded message, and ignores datagrams with a multicast source address. These four rules together stop the mechanism from becoming an off-host traffic amplifier; an implementation MUST keep all of them. A forwarded search whose reply address is all-zero is unusable and MUST be dropped.
6. TCP name servers
Where UDP is unavailable (routed WANs, restrictive firewalls, containers) a client MAY be configured with a list of TCP name servers. The client maintains persistent connections, retrying every 10s, and appends every constructed search message to each ready connection in addition to the UDP destinations. The same ring, batching, and reply handling apply — only the transport differs. [policy] A connection whose transmit buffer already holds more than 64KiB is skipped for that tick, on the assumption that a backlogged link is better retried on a later revolution.
Any PVA server can act as a name server: it answers CMD_SEARCH
on an established TCP connection exactly as over UDP,
but reports the port of the receiving listener.
The endpoint it names in a reply need not be itself.
7. Discovery
A search with an empty protocol list, zero names, and MustReply set
is a discovery ping: it asks every server to identify itself.
Servers answer with found=false and zero claimed names.
A client with discovery registered
treats such a reply exactly like a received beacon,
unifying active discovery and passive announcement into one code path.
Discovery pings are not driven by the ring —
they are sent once per registration, plus whenever the application asks.
Loss of a server is inferred from beacon timeout,
never from a negative response.
8. Scaling
Let N be the number of unresolved names,
L the mean name length,
D the number of search destinations,
and C = floor(1359 / (5 + L)) the names per datagram.
Steady state.
Each name is searched once per 30s,
so the aggregate rate is N/30 names/s
regardless of how the names were created:
packets/s per destination = N / (30 * C)
bytes/s per destination = N * (5 + L) / 30 (plus ~3% framing)
N |
packets/s (L=30) |
bit/s per destination |
per-tick burst |
|---|---|---|---|
1 000 |
0.9 |
~11kbit/s |
~1 packet |
10 000 |
8.8 |
~110kbit/s |
~9 packets |
100 000 |
88 |
~1.1Mbit/s |
~88 packets |
1 000 000 |
877 |
~11Mbit/s |
~877 packets |
Multiply by D. A million unresolved names on a broadcast subnet costs ~11Mbit/s per destination — which is exactly why the ring saturates rather than backing off further: the cost becomes bounded and predictable, and every host on the subnet must parse all of it.
Cost per resolved name is zero. A resolved Channel leaves the ring entirely. Search load is proportional to the unresolved set, not the connected set, so a client holding a million working Channels emits no search traffic at all.
Why a ring instead of per-Channel timers. For N names the ring costs O(1) timers and O(N/30) work per tick, with per-Channel state of one list node plus a small counter. Per-Channel timers would cost N timer registrations and an O(log N) priority-queue operation per retry — and, worse, would emit uncoordinated single-name datagrams instead of the ~38-name batches that make the packet rates above achievable. Batching buys two orders of magnitude, and batching requires many Channels to become due at the same instant. The ring makes “due at the same instant” the default, then deliberately spreads the result (cohort splitting, one bucket per tick) to avoid bursts.
Server-side cost is O(names per message × Sources) per datagram, entirely on the UDP worker thread, with no per-search allocation and no retained state. A server does not track which clients are searching for what, so a growing client population costs it only the arriving packet rate.
Load asymmetry. Broadcast search is the one PVA operation whose cost is borne by hosts with no interest in it. Every rule marked [policy] above — the 1400-byte cap, 30s saturation, batching, coalescing, cohort splitting — exists to reduce that externality rather than to benefit the searching client. An implementation that short-circuits them will interoperate correctly and still be a bad neighbour.
9. Constants
Quantity |
Value |
Role |
|---|---|---|
Ring size |
30 buckets |
Bounds the retry interval |
Normal tick |
1s |
One bucket drained per tick |
Fast tick (expedited) |
200ms |
One revolution ≈ 6s |
Initial coalescing delay |
10ms |
Batches newly created Channels |
Max steady-state search interval |
30s |
Ring size × normal tick |
Cohort split threshold |
100 entries |
Bucket imbalance triggering deferral |
Max search datagram |
1400 bytes |
MTU-safe |
Max datagrams per receive callback |
40 |
Reactor fairness |
|
30s |
Rate limit on expedited search |
Channel cache sweep |
10s |
Two consecutive sweeps discard |
Beacon tracking limit |
20000 entries |
Restart-loop containment |
Beacon expiry / check interval |
360s / 180s |
Server-lost detection |
Beacon send interval |
15s ×10, then 180s |
Fast announce, then steady |
Name-server reconnect check |
10s |
TCP search transport |
Name-server TX backlog skip |
64KiB |
Skip this tick, retry next revolution |
Default search (UDP) port |
5076 |
|
Default server (TCP) port |
5075 |
|
Configuration inputs: $EPICS_PVA_ADDR_LIST and $EPICS_PVA_AUTO_ADDR_LIST
(search destinations), $EPICS_PVA_NAME_SERVERS (TCP transport),
$EPICS_PVA_BROADCAST_PORT (search port),
$EPICS_PVA_SERVER_PORT (default TCP port),
$EPICS_PVAS_IGNORE_ADDR_LIST (server-side source filter),
and $EPICS_PVAS_BEACON_ADDR_LIST / $EPICS_PVAS_AUTO_BEACON_ADDR_LIST (beacon
destinations).
10. What is not promised
An implementation MUST NOT depend on any of the following; PVXS provides none of them.
No completion signal. There is no event, error, or timeout for “this name does not exist”. Application-level timeouts are the only bound.
No ordering. Names submitted together resolve in an unspecified order, and later ones MAY resolve first.
No fairness between names. All names in a bucket share one tick; which datagram a given name lands in is unspecified.
No at-most-once search. A name MAY be searched again after being claimed (a reply may be lost, or arrive after a retry was already sent), and a server MAY receive the same name repeatedly. Servers MUST be idempotent with respect to search.
No negative caching, no failover, no load balancing, no preference among servers. The first positive reply wins for the Channel’s lifetime.
No guarantee that a claim leads to a channel, and no guarantee that a created channel was ever claimed.
No relationship between
searchSequenceIDand correlation. CIDs correlate; the sequence number is echoed only for the benefit of peers that do use it.