Skip to content

Commit d666287

Browse files
Add upload_file and download_file support (#161)
* Add upload_file and download_file support to MultiProcessPssh with operation registry This commit addresses API incompatibility issues between Pssh and MultiProcessPssh classes and implements a robust operation registry system to prevent future drift. Key Changes: - Add missing upload_file and download_file methods to MultiProcessPssh class - Implement operation registry (SUPPORTED_OPERATIONS) as single source of truth - Use method names directly as operation names to eliminate mapping errors - Add missing detailed parameter support to MultiProcessPssh.exec() API Parity Testing: - Add comprehensive test suite to catch missing methods automatically - Validate method signatures match between Pssh and MultiProcessPssh - Verify operation registry completeness and consistency - Test runtime execution of all sharder operations with proper parameters Operations Updated: - exec -> exec (added detailed parameter support) - upload_file -> upload_file (new operation, matches method name) - download_file -> download_file (new operation, matches method name) This ensures MultiProcessPssh maintains full API compatibility with Pssh and prevents the class of issues that led to missing upload_file/download_file methods. Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Ignatious Johnson <ichristo@amd.com> * Refactor to ABC + composition + dynamic sharder architecture This commit addresses all PR review comments by completely refactoring the multiprocess SSH architecture to be cleaner, more maintainable, and more robust. Key Architectural Changes: - Replace inheritance with ABC + composition pattern - Eliminate registry + if/elif tree with dynamic method dispatch - Create single source of truth via ShardableSshInterface ABC - Move ABC to separate interfaces.py to avoid circular imports Review Comments Addressed: - #1-#2: Fixed operation name mismatches (upload/download) - #3: Updated test expectations for new calling convention - #4: Proper error handling with early ABC validation - #5: Maintained custom download_file result merging - #6: Implemented exact dynamic approach suggested by reviewer Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Ignatious Johnson <ichristo@amd.com> --------- Signed-off-by: Ignatious Johnson <ichristo@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 899ae5c commit d666287

6 files changed

Lines changed: 400 additions & 310 deletions

File tree

cvs/lib/parallel/interfaces.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'''
2+
Copyright 2025 Advanced Micro Devices, Inc.
3+
All rights reserved. This notice is intended as a precaution against inadvertent publication and does not imply publication or any waiver of confidentiality.
4+
The year included in the foregoing notice is the year of creation of the work.
5+
All code contained here is Property of Advanced Micro Devices, Inc.
6+
'''
7+
8+
from abc import ABC, abstractmethod
9+
10+
11+
class ShardableSshInterface(ABC):
12+
"""Abstract base class defining operations that MUST support sharding.
13+
14+
Any class implementing this interface must provide sharded implementations
15+
for all these methods. This prevents silent performance degradation where
16+
operations fall back to non-sharded implementations.
17+
"""
18+
19+
@abstractmethod
20+
def exec(self, cmd, timeout=None, print_console=True, detailed=False):
21+
"""Execute command - must support sharding for performance."""
22+
pass
23+
24+
@abstractmethod
25+
def exec_cmd_list(self, cmd_list, timeout=None, print_console=True):
26+
"""Execute command list - must support sharding for performance."""
27+
pass
28+
29+
@abstractmethod
30+
def upload_file(self, local_file, remote_file, recurse=False):
31+
"""Upload file via SFTP - must support sharding for performance."""
32+
pass
33+
34+
@abstractmethod
35+
def download_file(self, remote_file, local_file, recurse=False, suffix_separator='_'):
36+
"""Download file via SFTP - must support sharding for performance."""
37+
pass
38+
39+
@abstractmethod
40+
def reboot_connections(self):
41+
"""Reboot connections - must support sharding for performance."""
42+
pass

cvs/lib/parallel/multiprocess_pssh.py

Lines changed: 70 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
from cvs.lib.parallel.pssh import Pssh
1010
from cvs.lib.parallel.config import ParallelConfig
1111
from cvs.lib.parallel.pssh_sharder import PsshSharder
12+
from cvs.lib.parallel.interfaces import ShardableSshInterface
1213
from cvs.lib import globals
1314

1415
global_log = globals.log
1516

1617

17-
class MultiProcessPssh(Pssh):
18+
class MultiProcessPssh(ShardableSshInterface):
1819
"""
1920
Multi-process parallel SSH with automatic host sharding for large host lists.
2021
@@ -47,10 +48,16 @@ def __init__(
4748
n = len(host_list) if host_list is not None else 0
4849
use_mp = hosts_per_shard > 0 and n > hosts_per_shard
4950

50-
if not use_mp:
51-
# Use single-process base class
52-
super().__init__(
53-
log, # Pass through log parameter to parent
51+
if use_mp:
52+
# Initialize for multi-process sharding - no Pssh instance needed
53+
self._init_sharded(log, host_list, user, password, pkey, host_key_check, stop_on_errors, env_vars)
54+
self.sharder = PsshSharder(self.config)
55+
self._use_process_sharding = True
56+
self.pssh = None # No Pssh instance needed for sharded mode
57+
else:
58+
# No sharding - create Pssh instance for delegation
59+
self.pssh = Pssh(
60+
log,
5461
host_list,
5562
user,
5663
password,
@@ -63,12 +70,6 @@ def __init__(
6370
self._use_process_sharding = False
6471
# Ensure attributes needed by _shard_init_kwargs are available
6572
self.env_vars = env_vars
66-
else:
67-
# Initialize for multi-process sharding
68-
self._init_sharded(log, host_list, user, password, pkey, host_key_check, stop_on_errors, env_vars)
69-
70-
# Create the sharder - no registration needed with direct operations!
71-
self.sharder = PsshSharder(self.config)
7273

7374
def _init_sharded(
7475
self,
@@ -116,9 +117,8 @@ def _shard_init_kwargs(self):
116117

117118
def _merge_shard_returns(self, shard_returns, merge_unreachable=True):
118119
"""
119-
Update reachable/unreachable host lists from shard workers and convert results to cmd_output dict.
120+
Update reachable/unreachable host lists from shard workers and merge results to cmd_output dict.
120121
121-
Handles both state updates and SimpleHostOutput conversion in one place for cleaner architecture.
122122
Returns cmd_output dict ready for _print_merged_outputs.
123123
"""
124124
# 1. Update host lists (existing logic)
@@ -132,18 +132,12 @@ def _merge_shard_returns(self, shard_returns, merge_unreachable=True):
132132
if u not in self.unreachable_hosts:
133133
self.unreachable_hosts.append(u)
134134

135-
# 2. Convert SimpleHostOutput objects to cmd_output dict
135+
# 2. Merge results from shard workers (always dict format)
136136
merged = {}
137137
for shard in shard_returns:
138138
result = shard.get('result') or {} # treat None as {} (for scp/reboot)
139-
140-
if isinstance(result, list):
141-
# Handle SimpleHostOutput objects - convert using _process_output
142-
temp_dict = self._process_output(result, print_console=False)
143-
merged.update(temp_dict)
144-
else:
145-
# Handle old dict format (for scp/reboot)
146-
merged.update(result)
139+
# Shard workers always return processed dicts from _process_output
140+
merged.update(result)
147141

148142
# 3. Preserve original host order
149143
cmd_output = {}
@@ -171,10 +165,10 @@ def _print_merged_outputs(self, cmd_output, cmd=None, cmd_list=None, print_conso
171165
for line in cmd_output[host].splitlines():
172166
self.log.info("%s", line)
173167

174-
def exec(self, cmd, timeout=None, print_console=True):
168+
def exec(self, cmd, timeout=None, print_console=True, detailed=False):
175169
"""Execute command with automatic sharding if needed."""
176-
if not getattr(self, '_use_process_sharding', False):
177-
return super().exec(cmd, timeout=timeout, print_console=print_console)
170+
if not self._use_process_sharding:
171+
return self.pssh.exec(cmd, timeout=timeout, print_console=print_console, detailed=detailed)
178172

179173
if self.env_prefix:
180174
full_cmd = f"{self.env_prefix} ; {cmd}"
@@ -195,7 +189,7 @@ def exec(self, cmd, timeout=None, print_console=True):
195189
# Use sharder for sharded execution
196190
host_chunks = list(self.sharder.chunk_hosts(self.reachable_hosts))
197191
payloads = self.sharder.create_payloads(
198-
'exec', host_chunks, self._shard_init_kwargs(), cmd=cmd, timeout=timeout
192+
'exec', host_chunks, self._shard_init_kwargs(), cmd=cmd, timeout=timeout, detailed=detailed
199193
)
200194
shard_returns = self.sharder.execute_sharded(payloads)
201195

@@ -213,8 +207,8 @@ def exec(self, cmd, timeout=None, print_console=True):
213207

214208
def exec_cmd_list(self, cmd_list, timeout=None, print_console=True):
215209
"""Execute command list with automatic sharding if needed."""
216-
if not getattr(self, '_use_process_sharding', False):
217-
return super().exec_cmd_list(cmd_list, timeout=timeout, print_console=print_console)
210+
if not self._use_process_sharding:
211+
return self.pssh.exec_cmd_list(cmd_list, timeout=timeout, print_console=print_console)
218212

219213
if len(cmd_list) != len(self.host_list):
220214
raise ValueError('cmd_list length must match host_list length')
@@ -256,7 +250,7 @@ def exec_cmd_list(self, cmd_list, timeout=None, print_console=True):
256250
offset += k
257251
payloads.append(
258252
self.sharder.create_payloads(
259-
'cmd_list', [shard_hosts], self._shard_init_kwargs(), cmd_list=shard_commands, timeout=timeout
253+
'exec_cmd_list', [shard_hosts], self._shard_init_kwargs(), cmd_list=shard_commands, timeout=timeout
260254
)[0]
261255
)
262256

@@ -275,40 +269,75 @@ def exec_cmd_list(self, cmd_list, timeout=None, print_console=True):
275269
return cmd_output
276270

277271
def scp_file(self, local_file, remote_file, recurse=False):
278-
"""Copy file with automatic sharding if needed."""
279-
if not getattr(self, '_use_process_sharding', False):
280-
return super().scp_file(local_file, remote_file, recurse=recurse)
272+
"""
273+
Backward-compatible alias for upload_file.
281274
275+
Kept so existing callers (and log-grep tooling looking for the legacy
276+
"About to copy local file..." line) keep working. New code should call
277+
upload_file directly.
278+
"""
282279
self.log.info('About to copy local file {} to remote {} on all Hosts'.format(local_file, remote_file))
280+
return self.upload_file(local_file, remote_file, recurse=recurse)
281+
282+
def upload_file(self, local_file, remote_file, recurse=False):
283+
"""Upload file with automatic sharding if needed."""
284+
if not self._use_process_sharding:
285+
return self.pssh.upload_file(local_file, remote_file, recurse=recurse)
286+
287+
self.log.info('SFTP upload %s -> %s on %s', local_file, remote_file, self.reachable_hosts)
283288

284289
host_chunks = list(self.sharder.chunk_hosts(self.reachable_hosts))
285290
payloads = self.sharder.create_payloads(
286-
'scp',
291+
'upload_file',
287292
host_chunks,
288293
self._shard_init_kwargs(),
289294
local_file=local_file,
290295
remote_file=remote_file,
291296
recurse=recurse,
292297
)
293298
shard_returns = self.sharder.execute_sharded(payloads)
294-
return self._merge_shard_returns(shard_returns, merge_unreachable=False)
299+
return self._merge_shard_returns(shard_returns)
300+
301+
def download_file(self, remote_file, local_file, recurse=False, suffix_separator='_'):
302+
"""Download file with automatic sharding if needed."""
303+
if not self._use_process_sharding:
304+
return self.pssh.download_file(remote_file, local_file, recurse=recurse, suffix_separator=suffix_separator)
305+
306+
self.log.info('SFTP download %s -> %s from %s', remote_file, local_file, self.reachable_hosts)
307+
308+
host_chunks = list(self.sharder.chunk_hosts(self.reachable_hosts))
309+
payloads = self.sharder.create_payloads(
310+
'download_file',
311+
host_chunks,
312+
self._shard_init_kwargs(),
313+
remote_file=remote_file,
314+
local_file=local_file,
315+
recurse=recurse,
316+
suffix_separator=suffix_separator,
317+
)
318+
shard_returns = self.sharder.execute_sharded(payloads)
319+
return self._merge_shard_returns(shard_returns)
295320

296321
def reboot_connections(self):
297322
"""Reboot connections with automatic sharding if needed."""
298-
if not getattr(self, '_use_process_sharding', False):
299-
return super().reboot_connections()
323+
if not self._use_process_sharding:
324+
return self.pssh.reboot_connections()
300325

301326
self.log.info('Rebooting Connections')
302327

303328
host_chunks = list(self.sharder.chunk_hosts(self.reachable_hosts))
304-
payloads = self.sharder.create_payloads('reboot', host_chunks, self._shard_init_kwargs())
329+
payloads = self.sharder.create_payloads('reboot_connections', host_chunks, self._shard_init_kwargs())
305330
shard_returns = self.sharder.execute_sharded(payloads)
306331
return self._merge_shard_returns(shard_returns, merge_unreachable=False)
307332

308333
def destroy_clients(self):
309334
"""Destroy clients - handle both sharded and non-sharded modes."""
310-
if getattr(self, '_use_process_sharding', False):
311-
self.log.info('Destroying Current phdl connections ..')
335+
if self._use_process_sharding:
336+
self.log.info('Cleaning up sharded mode state ..')
337+
# In sharded mode, no persistent connections to destroy
312338
self.client = None
313-
return
314-
super().destroy_clients()
339+
else:
340+
self.log.info('Destroying Current phdl connections ..')
341+
# In non-sharded mode, properly destroy the Pssh instance connections
342+
if self.pssh is not None:
343+
self.pssh.destroy_clients()

cvs/lib/parallel/pssh.py

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,6 @@
1717
global_log = globals.log
1818

1919

20-
class SimpleHostOutput:
21-
"""Simple HostOutput-compatible object for consistent processing across process boundaries."""
22-
23-
def __init__(self, host, stdout_lines, stderr_lines, exception, exit_code=0):
24-
self.host = host
25-
self.stdout = iter(stdout_lines) # Convert list to iterator for _process_output compatibility
26-
self.stderr = iter(stderr_lines) # Convert list to iterator for _process_output compatibility
27-
self.exception = exception
28-
self.exit_code = exit_code
29-
30-
3120
class Pssh:
3221
"""
3322
Single-process parallel SSH: one ParallelSSHClient (one gevent hub) over a host list.
@@ -385,32 +374,6 @@ def reboot_connections(self):
385374
self.log.info('Rebooting Connections')
386375
self.client.run_command('reboot -f', stop_on_errors=self.stop_on_errors)
387376

388-
def _extract_simple_data(self, output):
389-
"""
390-
Extract essential data from pssh.output.HostOutput objects for safe IPC transfer.
391-
392-
Converts non-picklable HostOutput objects (containing active SSH channels,
393-
generators, and C extension objects) into picklable SimpleHostOutput objects
394-
by consuming stdout/stderr generators into lists and extracting core attributes.
395-
"""
396-
397-
simple_outputs = []
398-
for item in output:
399-
# Consume generators to lists for pickling
400-
stdout_lines = list(item.stdout or [])
401-
stderr_lines = list(item.stderr or [])
402-
403-
simple_output = SimpleHostOutput(
404-
host=item.host,
405-
stdout_lines=stdout_lines,
406-
stderr_lines=stderr_lines,
407-
exception=item.exception,
408-
exit_code=getattr(item, 'exit_code', 0),
409-
)
410-
simple_outputs.append(simple_output)
411-
412-
return simple_outputs
413-
414377
def destroy_clients(self):
415378
self.log.info('Destroying Current phdl connections ..')
416379
del self.client

cvs/lib/parallel/pssh_sharder.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@
55
All code contained here is Property of Advanced Micro Devices, Inc.
66
'''
77

8+
import inspect
89
import multiprocessing as mp
910
from concurrent.futures import ProcessPoolExecutor, as_completed
1011

1112
from cvs.lib.parallel.pssh import Pssh
13+
from cvs.lib.parallel.interfaces import ShardableSshInterface
14+
15+
16+
# Dynamically discover supported operations from ABC (computed once at import time)
17+
18+
SUPPORTED_OPERATIONS = {
19+
name for name, method in inspect.getmembers(ShardableSshInterface) if getattr(method, '__isabstractmethod__', False)
20+
}
1221

1322

1423
class PsshSharder:
@@ -74,6 +83,8 @@ def run_shard(payload):
7483
"""
7584
Run an SSH operation on a shard of hosts (must be picklable for multiprocessing).
7685
86+
Dynamically supports all abstract methods defined in ShardableSshInterface.
87+
7788
Args:
7889
payload: Dict with 'operation' (operation type), 'init' (SSH setup), and operation args
7990
@@ -84,29 +95,35 @@ def run_shard(payload):
8495
if not isinstance(operation, str):
8596
raise TypeError('payload["operation"] must be str, got %r' % (type(operation),))
8697

98+
# Validate operation is supported by ABC
99+
if operation not in SUPPORTED_OPERATIONS:
100+
raise ValueError(f'Unknown operation: {operation}. Supported: {sorted(SUPPORTED_OPERATIONS)}')
101+
87102
# Create SSH client for this shard of hosts
88103
init_kwargs = payload['init']
89104
init_kwargs['process_output'] = False # Force raw output mode for sharding
90105
shard = Pssh(**init_kwargs)
91106

92107
try:
93-
# Direct operation calls - no registry needed!
94-
if operation == 'exec':
95-
result = shard.exec(payload['cmd'], timeout=payload.get('timeout'), print_console=False)
96-
elif operation == 'cmd_list':
97-
result = shard.exec_cmd_list(
98-
payload['cmd_list'],
99-
timeout=payload.get('timeout'),
100-
print_console=False,
101-
)
102-
elif operation == 'scp':
103-
shard.scp_file(payload['local_file'], payload['remote_file'], recurse=payload.get('recurse', False))
104-
result = None
105-
elif operation == 'reboot':
106-
shard.reboot_connections()
108+
# Ensure method exists in Pssh
109+
if not hasattr(shard, operation):
110+
raise RuntimeError(f'Method {operation} not found in Pssh class')
111+
112+
shard_method = getattr(shard, operation)
113+
114+
# Extract operation arguments from payload (excluding 'operation' and 'init')
115+
args = {k: v for k, v in payload.items() if k not in ['operation', 'init']}
116+
117+
# Add common parameters for operations that support them
118+
if operation in ['exec', 'exec_cmd_list']:
119+
args['print_console'] = False # Always False for sharded operations
120+
121+
# Call the method dynamically
122+
result = shard_method(**args)
123+
124+
# Handle operations that should return None (void operations)
125+
if operation in ['upload_file', 'reboot_connections']:
107126
result = None
108-
else:
109-
raise ValueError(f'Unknown operation: {operation}')
110127

111128
return {
112129
'result': result,

0 commit comments

Comments
 (0)