Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/src/pyrogue_tree/client_interfaces/zmq_server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ From the client perspective, this gives access to the same tree content
available inside the Root process: read/write Variables, execute Commands, and
monitor value updates.

Request Serialization
=====================

ZMQ request/reply operations are serialized through the server Root's
``operationLock()``. This means multiple remote clients do not execute tree
operations concurrently with each other, and they also wait behind local
root-level operations such as YAML configuration loads, ``ReadAll``, or
``WriteAll``. Root lifecycle operations such as ``Initialize``, ``HardReset``,
and ``CountReset`` also use the same lock.

This is usually the desired behavior for control applications: a client should
not read or write the tree halfway through a long ``LoadConfig`` operation.
The tradeoff is that remote requests can wait while a long serialized operation
is in progress. Client-side request/retry logging is intentionally throttled so
those waits are visible without logging every socket-timeout interval.

Asynchronous variable update publishing is still handled by the Root update
queue and the server's publish socket. The operation lock serializes the
request/reply execution path; it is not a replacement for lower-level memory
transaction locking.

Common Usage Pattern
====================

Expand Down
5 changes: 5 additions & 0 deletions docs/src/pyrogue_tree/core/device.rst
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,11 @@ operate on the device subtree using device-relative paths. This allows
configuration of individual devices without needing to know the full tree
structure above the device.

These methods are live-tree operations. The device must be attached to a
``Root``, and PyRogue serializes the operation with ``Root.operationLock()`` so
device-level YAML work does not interleave with root-level YAML operations,
whole-tree reads/writes, or ZMQ client requests.

.. code-block:: python

with root:
Expand Down
46 changes: 46 additions & 0 deletions docs/src/pyrogue_tree/core/root.rst
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,52 @@ with background poll reads.
For the detailed scheduling and behavior model, see
:doc:`/pyrogue_tree/core/poll_queue`.

Root Operation Lock
===================

``Root`` owns a reentrant operation lock used to serialize long-running
root-level operations. This is mostly a behind-the-scenes guard: normal users
do not need to acquire it when using built-in APIs. PyRogue acquires it
automatically for:

* ``ReadAll`` and ``WriteAll`` root operations
* ``Initialize``, ``HardReset``, and ``CountReset`` root lifecycle operations
* YAML import/export helpers such as ``getYaml``, ``saveYaml``, ``loadYaml``,
and ``setYaml``
* ``RemoteVariableDump`` and ``RemoteConfigDump``
* ZMQ request/reply operations served by :py:class:`~pyrogue.interfaces.ZmqServer`

The goal is to prevent operations that change or snapshot a coordinated tree
state from interleaving with each other. For example, if one client starts a
long ``LoadConfig`` operation, another ZMQ client request will wait until that
operation completes instead of reading or writing the tree halfway through the
load.

Application authors usually only need to know about this lock when they create
custom commands or callbacks that perform a multi-step system operation. If the
operation must not interleave with YAML loads, root reads/writes, lifecycle
operations, or remote client requests, wrap it with ``operationLock()``:

.. code-block:: python

@pyrogue.command(name='ApplyMode', value='')
def _applyMode(self, arg):
with self.operationLock():
self.Control.Mode.setDisp(arg)
self.Control.Enable.set(True)
self.WriteAll()

The lock is reentrant, so code inside the block can safely call built-in
helpers that also acquire the lock. Keep the protected section limited to the
coordinated operation. The operation lock is not a replacement for lower-level
memory transaction locking, and it does not stop asynchronous value update
publishing; those mechanisms remain handled by the block, polling, and update
queue paths.

YAML helpers operate on a live tree attached to a ``Root``. Calling YAML
helpers on detached ``Device`` or ``Node`` objects is treated as a tree
construction error.

YAML Configuration And Bulk Operations
======================================

Expand Down
20 changes: 20 additions & 0 deletions docs/src/pyrogue_tree/core/yaml_configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ These APIs also back the built-in hidden Root commands:
The command wrappers mainly pre-select useful defaults for modes and group
filters.

Operation Serialization
=======================

YAML helpers operate on a live tree attached to a ``Root``. PyRogue serializes
``getYaml``, ``saveYaml``, ``loadYaml``, and ``setYaml`` with the
``Root.operationLock()`` context. This prevents YAML snapshots and
configuration loads from interleaving with other root-level operations, such as
``ReadAll``, ``WriteAll``, ``Initialize``, ``HardReset``, ``CountReset``,
another YAML operation, or a request from a ZMQ client.

For most applications this is automatic and no user code is needed. It matters
when designing custom application commands: if a command performs a coordinated
multi-step operation that must not overlap with YAML loads or remote client
requests, wrap that command body in ``Root.operationLock()``. See
:doc:`/pyrogue_tree/core/root` for the broader operation-lock model.

Calling YAML helpers on detached ``Device`` or ``Node`` objects is not a
supported configuration path. Build the tree under a ``Root`` first, then call
YAML APIs on the Root or on devices in that rooted tree.

Configuration Vs State Filters
==============================

Expand Down
215 changes: 118 additions & 97 deletions python/pyrogue/_Device.py
Original file line number Diff line number Diff line change
Expand Up @@ -790,26 +790,33 @@ def saveYaml(
-------
bool
Returns ``True`` when export completes.

Notes
-----
The device must be attached to a Root. The export is serialized with
the Root operation lock so it does not interleave with other root-level
operations such as YAML loads or ZMQ requests.
"""
with self._operationLock():

# Auto generate name if no arg
if name is None or name == '':
name = datetime.datetime.now().strftime(autoPrefix + "_%Y%m%d_%H%M%S.yml")
# Auto generate name if no arg
if name is None or name == '':
name = datetime.datetime.now().strftime(autoPrefix + "_%Y%m%d_%H%M%S.yml")

if autoCompress:
name += '.zip'
if autoCompress:
name += '.zip'

yml = self.getYaml(readFirst=readFirst,modes=modes,incGroups=incGroups,excGroups=excGroups, recurse=True)
yml = self.getYaml(readFirst=readFirst,modes=modes,incGroups=incGroups,excGroups=excGroups, recurse=True)

if name.split('.')[-1] == 'zip':
with zipfile.ZipFile(name, 'w', compression=zipfile.ZIP_LZMA) as zf:
with zf.open(os.path.basename(name[:-4]),'w') as f:
f.write(yml.encode('utf-8'))
else:
with open(name,'w') as f:
f.write(yml)
if name.split('.')[-1] == 'zip':
with zipfile.ZipFile(name, 'w', compression=zipfile.ZIP_LZMA) as zf:
with zf.open(os.path.basename(name[:-4]),'w') as f:
f.write(yml.encode('utf-8'))
else:
with open(name,'w') as f:
f.write(yml)

return True
return True

def loadYaml(
self,
Expand Down Expand Up @@ -838,91 +845,98 @@ def loadYaml(
-------
bool
Returns ``True`` when load completes.

Notes
-----
The device must be attached to a Root. The load is serialized with the
Root operation lock so it does not interleave with other root-level
operations such as YAML exports or ZMQ requests.
"""
with self._operationLock():

# Pass arg is a python list
if isinstance(name,list):
rawlst = name
# Pass arg is a python list
if isinstance(name,list):
rawlst = name

# Passed arg is a comma separated list of files
elif ',' in name:
rawlst = name.split(',')
# Passed arg is a comma separated list of files
elif ',' in name:
rawlst = name.split(',')

# Not a list
else:
rawlst = [name]

# Init final list
lst = []
# Not a list
else:
rawlst = [name]

# Iterate through raw list and look for directories
for rl in rawlst:
# Init final list
lst = []

# Name ends with .yml or .yaml
if rl[-4:] == '.yml' or rl[-5:] == '.yaml':
lst.append(rl)
# Iterate through raw list and look for directories
for rl in rawlst:

# Entry is a zip file directory
elif '.zip' in rl:
base = rl.split('.zip')[0] + '.zip'
sub = rl.split('.zip')[1][1:]
# Name ends with .yml or .yaml
if rl[-4:] == '.yml' or rl[-5:] == '.yaml':
lst.append(rl)

# Open zipfile
with zipfile.ZipFile(base, 'r', compression=zipfile.ZIP_LZMA) as myzip:
# Entry is a zip file directory
elif '.zip' in rl:
base = rl.split('.zip')[0] + '.zip'
sub = rl.split('.zip')[1][1:]

# Check if passed name is a directory, otherwise generate an error
if not any(x.startswith("%s/" % sub.rstrip("/")) for x in myzip.namelist()):
raise Exception("loadYaml: Invalid load file: {}, must be a directory or end in .yml or .yaml".format(rl))
# Open zipfile
with zipfile.ZipFile(base, 'r', compression=zipfile.ZIP_LZMA) as myzip:

else:
zip_yaml = []
# Check if passed name is a directory, otherwise generate an error
if not any(x.startswith("%s/" % sub.rstrip("/")) for x in myzip.namelist()):
raise Exception("loadYaml: Invalid load file: {}, must be a directory or end in .yml or .yaml".format(rl))

# Iterate through directory contents
for zfn in myzip.namelist():
else:
zip_yaml = []

# Filter by base directory
if zfn.find(sub) == 0:
spt = zfn.split('%s/' % sub.rstrip('/'))[1]
# Iterate through directory contents
for zfn in myzip.namelist():

# Entry ends in .yml or *.yml and is in current directory
if '/' not in spt and (spt[-4:] == '.yml' or spt[-5:] == '.yaml'):
zip_yaml.append(base + '/' + zfn)
# Filter by base directory
if zfn.find(sub) == 0:
spt = zfn.split('%s/' % sub.rstrip('/'))[1]

# Keep zip-directory loads aligned with normal directory
# loads by applying a lexicographic pathname sort.
lst.extend(sorted(zip_yaml))
# Entry ends in .yml or *.yml and is in current directory
if '/' not in spt and (spt[-4:] == '.yml' or spt[-5:] == '.yaml'):
zip_yaml.append(base + '/' + zfn)

# Entry is a directory
elif os.path.isdir(rl):
dlst = glob.glob('{}/*.yml'.format(rl))
dlst.extend(glob.glob('{}/*.yaml'.format(rl)))
lst.extend(sorted(dlst))
# Keep zip-directory loads aligned with normal directory
# loads by applying a lexicographic pathname sort.
lst.extend(sorted(zip_yaml))

# Not a zipfile, not a directory and does not end in .yml
else:
raise Exception("loadYaml: Invalid load file: {}, must be a directory or end in .yml or .yaml".format(rl))

self._log.info(
"Loading YAML config from %s file(s), writeEach=%s, modes=%s, incGroups=%s, excGroups=%s",
len(lst),
writeEach,
modes,
incGroups,
excGroups,
)
# Entry is a directory
elif os.path.isdir(rl):
dlst = glob.glob('{}/*.yml'.format(rl))
dlst.extend(glob.glob('{}/*.yaml'.format(rl)))
lst.extend(sorted(dlst))

# Read each file
with self.root.pollBlock(), self.root.updateGroup():
for fn in lst:
self._log.debug("Applying YAML config file %s", fn)
d = pr.yamlToData(fName=fn)
self._applyYamlDict(d=d,writeEach=writeEach,modes=modes,incGroups=incGroups,excGroups=excGroups)

if not writeEach:
self._log.info("Committing staged YAML config to hardware")
self._writeConfig()

return True
# Not a zipfile, not a directory and does not end in .yml
else:
raise Exception("loadYaml: Invalid load file: {}, must be a directory or end in .yml or .yaml".format(rl))

self._log.info(
"Loading YAML config from %s file(s), writeEach=%s, modes=%s, incGroups=%s, excGroups=%s",
len(lst),
writeEach,
modes,
incGroups,
excGroups,
)

# Read each file
with self.root.pollBlock(), self.root.updateGroup():
for fn in lst:
self._log.debug("Applying YAML config file %s", fn)
d = pr.yamlToData(fName=fn)
self._applyYamlDict(d=d,writeEach=writeEach,modes=modes,incGroups=incGroups,excGroups=excGroups)

if not writeEach:
self._log.info("Committing staged YAML config to hardware")
self._writeConfig()

return True

def setYaml(
self,
Expand All @@ -946,23 +960,30 @@ def setYaml(
Group name or group names to include.
excGroups : str or list[str], optional
Group name or group names to exclude.
"""
d = pr.yamlToData(yml)

self._log.info(
"Applying YAML text config, writeEach=%s, modes=%s, incGroups=%s, excGroups=%s",
writeEach,
modes,
incGroups,
excGroups,
)

with self.root.pollBlock(), self.root.updateGroup():
self._applyYamlDict(d=d,writeEach=writeEach,modes=modes,incGroups=incGroups,excGroups=excGroups)
Notes
-----
The device must be attached to a Root. The apply operation is
serialized with the Root operation lock so it does not interleave with
other root-level operations such as YAML exports or ZMQ requests.
"""
with self._operationLock():
d = pr.yamlToData(yml)

self._log.info(
"Applying YAML text config, writeEach=%s, modes=%s, incGroups=%s, excGroups=%s",
writeEach,
modes,
incGroups,
excGroups,
)

with self.root.pollBlock(), self.root.updateGroup():
self._applyYamlDict(d=d,writeEach=writeEach,modes=modes,incGroups=incGroups,excGroups=excGroups)

if not writeEach:
self._log.info("Committing staged YAML text config to hardware")
self._writeConfig()
if not writeEach:
self._log.info("Committing staged YAML text config to hardware")
self._writeConfig()

def _applyYamlDict(
self,
Expand Down
Loading
Loading