Files
robertvoy-ComfyUI-Distributed/tests/test_image_list_native_cache_identity.py
T

537 lines
25 KiB
Python

"""Stable content signatures with fresh, expiring delivery capabilities."""
import asyncio
import copy
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import types
from unittest.mock import AsyncMock
import pytest
import torch
from image_list import runtime as rt
ROOT = Path(__file__).resolve().parents[1]
def item():
return rt.normalize_image_list_inputs(
torch.arange(18, dtype=torch.float64).reshape(1, 2, 3, 3),
torch.zeros(1, 2, 3, dtype=torch.bfloat16),
{'value_1': 'original', 'value_2': True, 'value_3': 7, 'value_4': 0.25,
'value_10': torch.ones(2, 3, dtype=torch.float16)})[0]
def graph():
return {
'source': {'class_type': 'Source', 'inputs': {}},
'start': {'class_type': rt.START_CLASS, 'inputs': {
'images': ['source', 0], 'image_list_execution_id': 'transient',
'image_list_workflow_meta': {'job': 'metadata'},
'image_list_enabled_worker_ids': '["worker"]',
'image_list_master_callback': 'http://old-master',
'values.value_1': 'inherited', 'worker_context_id': 'old-context'}},
'op': {'class_type': 'Operation', 'inputs': {'image': ['start', 0]}},
'end': {'class_type': rt.END_CLASS, 'inputs': {
'images': ['op', 0], 'context': ['start', 3], 'value_3': ['start', 2]}},
}
def worker_prompt(token, context, data, *, local=True):
prompt = graph()
result = rt.build_worker_prompt(prompt, rt.validate_image_list_regions(prompt)[0],
token, context, data['index'], 'http://master',
item_identity=rt.item_content_identity(data))
if local:
result['start']['inputs']['image_list_local_execution'] = True
result['end']['inputs']['image_list_local_execution'] = True
return result
@pytest.fixture(autouse=True)
def clean_state():
yield
rt._WORKER_ITEMS.clear()
rt._MASTER_JOBS.clear()
@pytest.fixture
def boundaries(monkeypatch):
"""Load only the boundary module; restore every replaced module on exit."""
name = '_native_cache_identity_tests'
for suffix in ('', '.nodes', '.utils', '.image_list', '.api'):
module = types.ModuleType(name + suffix)
module.__path__ = [str(ROOT / suffix.lstrip('.').replace('.', '/'))]
monkeypatch.setitem(sys.modules, name + suffix, module)
monkeypatch.setitem(sys.modules, name + '.image_list.runtime', rt)
io = types.SimpleNamespace(ComfyNode=type('ComfyNode', (), {}),
NodeOutput=lambda *args: args)
for cls in ('Image', 'Mask', 'String', 'Int', 'Float', 'Boolean'):
setattr(io, cls, object())
monkeypatch.setitem(sys.modules, 'comfy_api', types.ModuleType('comfy_api'))
monkeypatch.setitem(sys.modules, 'comfy_api.v0_0_2', types.SimpleNamespace(io=io))
monkeypatch.setitem(sys.modules, name + '.utils.async_helpers',
types.SimpleNamespace(run_async_in_server_loop=lambda coro, **kw: asyncio.run(coro)))
spec = importlib.util.spec_from_file_location(name + '.nodes.image_list', ROOT / 'nodes/image_list.py')
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)
return module
def start(boundaries, prompt):
boundaries.DistributedImageListStart.hidden = types.SimpleNamespace(prompt=prompt, unique_id='start')
return boundaries.DistributedImageListStart.execute(**prompt['start']['inputs'])
def end(boundaries, prompt, cached):
boundaries.DistributedImageListEnd.hidden = types.SimpleNamespace(prompt=prompt, unique_id='end')
inputs = {k: v for k, v in prompt['end']['inputs'].items()
if k not in ('images', 'context', 'value_3')}
return boundaries.DistributedImageListEnd.execute(
images=cached[0], context=cached[3], value_3=cached[2], **inputs)
def stage(data):
token = rt.create_worker_task_token()
rt.store_worker_item(token, rt.encode_item(data))
return token
def master(data, token):
context = rt.create_master_job('start', 'end', graph(), [data], ['worker'], '')
rt.register_assignment(context, data['index'], 'worker', token)
return context
def test_identity_is_exact_typed_order_independent_and_not_transport_bounded(monkeypatch):
original = item()
identity = rt.item_content_identity(original)
assert rt.is_valid_item_identity(identity)
reordered = copy.deepcopy(original)
reordered['values'] = dict(reversed(list(reordered['values'].items())))
assert rt.item_content_identity(reordered) == identity
strided = copy.deepcopy(original)
strided['image'] = original['image'].transpose(1, 2).contiguous().transpose(1, 2)
assert not strided['image'].is_contiguous()
assert rt.item_content_identity(strided) == identity
monkeypatch.setattr(rt, 'MAX_PAYLOAD_BYTES', 1)
with pytest.raises(ValueError, match='transport limit'):
rt.encode_item(original)
assert rt.item_content_identity(original) == identity
scalar_identities = set()
for value in (True, 1, 1.0, '1', 0.0, -0.0):
changed = copy.deepcopy(original)
changed['values']['value_2'] = value
scalar_identities.add(rt.item_content_identity(changed))
assert len(scalar_identities) == 6
@pytest.mark.parametrize('change', ['image', 'mask', 'value', 'index', 'dtype', 'shape', 'slot', 'missing-mask'])
def test_every_item_component_invalidates_identity(change):
original, changed = item(), item()
if change in ('image', 'mask'):
changed[change].reshape(-1)[0] = 1
elif change == 'value':
changed['values']['value_10'][0, 0] = 2
elif change == 'index':
changed['index'] = 1
elif change == 'dtype':
changed['image'] = changed['image'].float()
elif change == 'shape':
changed['values']['value_10'] = changed['values']['value_10'].reshape(3, 2)
elif change == 'slot':
changed['values']['value_9'] = changed['values'].pop('value_10')
else:
changed['mask'] = None
assert rt.item_content_identity(original) != rt.item_content_identity(changed)
def test_float64_low_bits_and_bfloat16_bytes_are_not_quantized():
data, changed = item(), item()
changed['image'].reshape(-1)[0] = torch.nextafter(torch.tensor(0., dtype=torch.float64),
torch.tensor(1., dtype=torch.float64))
assert rt.item_content_identity(data) != rt.item_content_identity(changed)
changed = item()
changed['mask'].view(torch.int16).reshape(-1)[0] = 1
assert rt.item_content_identity(data) != rt.item_content_identity(changed)
def test_stable_start_replaced_wholesale_and_legacy_graph_preserved():
prompt = graph()
frozen = copy.deepcopy(prompt)
region = rt.validate_image_list_regions(prompt)[0]
legacy = rt.build_worker_prompt(prompt, region, 'a', 'ctx-a', 0, 'http://a')
assert legacy['start']['inputs']['worker_item_token'] == 'a'
assert legacy['start']['inputs']['image_list_execution_id'] == 'transient'
identity = rt.item_content_identity(item())
a = rt.build_worker_prompt(prompt, region, 'a', 'ctx-a', 0, 'http://a', item_identity=identity)
b = rt.build_worker_prompt(prompt, region, 'b', 'ctx-b', 0, 'http://b', item_identity=identity)
assert a['start']['inputs'] == b['start']['inputs'] == {'worker_item_identity': identity}
assert a['end']['inputs']['worker_context_id'] == 'ctx-a'
assert b['end']['inputs']['worker_result_token'] == 'b'
assert b['end']['inputs']['worker_item_identity'] == identity
assert b['end']['inputs']['value_3'] == ['start', 2]
assert prompt == frozen
@pytest.mark.parametrize('bad', ['', 'sha256:' + 'z' * 64, 'a' * 64, False, ['bad']])
def test_malformed_identity_rejected(bad):
assert not rt.is_valid_item_identity(bad)
prompt = graph()
with pytest.raises((ValueError, TypeError), match='identity'):
rt.build_worker_prompt(prompt, rt.validate_image_list_regions(prompt)[0],
'token', 'context', 0, '', item_identity=bad)
def test_cache_miss_then_hit_delivers_current_context_token_and_values(boundaries):
data = item()
a = stage(data)
context_a = master(data, a)
prompt_a = worker_prompt(a, context_a, data)
cached = start(boundaries, prompt_a)
assert cached[3] == [rt.item_content_identity(data)]
assert 'image' not in rt._WORKER_ITEMS[a][1] # only a small, expiring receipt remains
end(boundaries, prompt_a, cached)
assert a not in rt._WORKER_ITEMS
assert rt.get_master_job(context_a).results[0]['values'] == {'value_3': 0}
b = stage(data)
context_b = master(data, b)
prompt_b = worker_prompt(b, context_b, data)
assert prompt_a['start'] == prompt_b['start']
# A native hit does NOT execute Start; End must consume this current upload.
end(boundaries, prompt_b, cached)
assert b not in rt._WORKER_ITEMS
state = rt.get_master_job(context_b)
assert state.expected_tokens[0] == b
assert torch.equal(state.results[0]['image'], data['image'])
assert rt._collect_output_lists([state.results[0]], state=state, include_values=True)[3][0]['value_3'] == 0
with pytest.raises(RuntimeError, match='missing|expired|consumed'):
end(boundaries, prompt_b, cached)
@pytest.mark.parametrize('miss', [False, True])
def test_expired_item_cannot_deliver_even_with_cached_start(boundaries, miss):
data = item()
token = stage(data)
context = master(data, token)
prompt = worker_prompt(token, context, data)
cached = start(boundaries, prompt) if miss else ([data['image']], [data['mask']], [0], [rt.item_content_identity(data)])
_, staged = rt._WORKER_ITEMS[token]
rt._WORKER_ITEMS[token] = (0, staged)
with pytest.raises(RuntimeError, match='missing|expired|consumed'):
end(boundaries, prompt, cached)
assert not rt.get_master_job(context).received_indexes
assert token not in rt._WORKER_ITEMS
def test_identity_mismatch_on_start_and_on_cache_hit_fails_closed(boundaries):
data, different = item(), item()
different['image'][0, 0, 0, 0] = 3
for hit in (False, True):
token = stage(different)
context = master(different, token)
prompt = worker_prompt(token, context, data)
cached = ([data['image']], [data['mask']], [0], [rt.item_content_identity(data)])
with pytest.raises((ValueError, RuntimeError), match='identity'):
end(boundaries, prompt, cached) if hit else start(boundaries, prompt)
assert not rt.get_master_job(context).received_indexes
rt.delete_worker_item(token)
@pytest.mark.parametrize('change', ['ambiguous', 'wrong-link', 'wrong-start-id', 'end-identity', 'end-index'])
def test_start_requires_exact_paired_end_binding(boundaries, change):
data = item()
token = stage(data)
prompt = worker_prompt(token, 'context', data)
if change == 'ambiguous':
prompt['second-end'] = copy.deepcopy(prompt['end'])
elif change == 'wrong-link':
prompt['end']['inputs']['context'] = ['start', 2]
elif change == 'wrong-start-id':
prompt['renamed'] = prompt.pop('start')
elif change == 'end-identity':
prompt['end']['inputs']['worker_item_identity'] = 'sha256:' + '0' * 64
else:
prompt['end']['inputs']['worker_item_index'] = 1
boundaries.DistributedImageListStart.hidden = types.SimpleNamespace(prompt=prompt, unique_id='start')
with pytest.raises((RuntimeError, ValueError), match='paired|identity|index'):
boundaries.DistributedImageListStart.execute(worker_item_identity=rt.item_content_identity(data),
image_list_local_execution=True)
def test_end_checks_stable_context_before_delivering(boundaries):
data = item()
token = stage(data)
context = master(data, token)
prompt = worker_prompt(token, context, data)
cached = list(start(boundaries, prompt))
cached[3] = ['sha256:' + '0' * 64]
with pytest.raises((RuntimeError, ValueError), match='identity|context'):
end(boundaries, prompt, cached)
assert not rt.get_master_job(context).received_indexes
def test_legacy_worker_path_still_consumes_and_delivers_job_context(boundaries):
data = item()
token = stage(data)
context = master(data, token)
prompt = graph()
prompt = rt.build_worker_prompt(prompt, rt.validate_image_list_regions(prompt)[0], token, context, 0, '')
prompt['start']['inputs']['image_list_local_execution'] = True
prompt['end']['inputs']['image_list_local_execution'] = True
cached = start(boundaries, prompt)
assert cached[3] == [context]
assert token not in rt._WORKER_ITEMS
end(boundaries, prompt, cached)
assert rt.get_master_job(context).received_indexes == {0}
@pytest.mark.parametrize('fmt', ['exact', 'png'])
def test_remote_cache_hit_submits_fresh_callback_metadata(boundaries, monkeypatch, fmt):
data = item()
token = stage(data)
prompt = worker_prompt(token, 'fresh-context', data, local=False)
prompt['end']['inputs'].update(transfer_format=fmt, transfer_quality=73)
submit = AsyncMock()
monkeypatch.setattr(boundaries, 'submit_worker_result', submit)
cached = ([data['image']], [data['mask']], [0], [rt.item_content_identity(data)])
result = end(boundaries, prompt, cached)
assert len(result) == 4
assert submit.await_args.args[:4] == ('http://master', token, 'fresh-context', 0)
assert submit.await_args.args[4]['values']['value_3'] == {'type': 'INT', 'value': 0}
payload = submit.await_args.args[4]
if fmt == 'png':
assert payload['image']['descriptor']['format'] == fmt
assert payload['image']['descriptor']['quality'] == 73
else:
assert 'encoding' not in payload['image']
assert token not in rt._WORKER_ITEMS
def test_item_route_returns_server_computed_identity(boundaries, monkeypatch):
from aiohttp import web
name = '_native_cache_identity_tests'
server = types.SimpleNamespace(PromptServer=types.SimpleNamespace(instance=types.SimpleNamespace(routes=web.RouteTableDef())))
monkeypatch.setitem(sys.modules, 'server', server)
spec = importlib.util.spec_from_file_location(name + '.api.image_list_routes', ROOT / 'api/image_list_routes.py')
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)
data = item()
token = rt.create_worker_task_token()
monkeypatch.setattr(module, '_json_body', AsyncMock(return_value={
'token': token, 'context_id': 'context', 'item': rt.encode_item(data),
'item_identity': 'sha256:' + '0' * 64}))
response = asyncio.run(module.image_list_item_endpoint(object()))
assert response.status == 200
assert json.loads(response.body)['item_identity'] == rt.item_content_identity(data)
assert len(rt._WORKER_ITEMS[token]) == 2
@pytest.mark.parametrize('responses', [
('valid', 'valid'), ('valid', 'absent'), ('invalid', 'valid'),
('bad-json', 'valid'), ('non-object', 'valid'),
])
def test_runtime_opts_in_per_upload_and_can_downgrade(monkeypatch, responses):
name = '_native_cache_identity_runtime'
for suffix in ('', '.image_list', '.api', '.api.orchestration', '.utils'):
module = types.ModuleType(name + suffix)
module.__path__ = [str(ROOT / suffix.lstrip('.').replace('.', '/'))]
monkeypatch.setitem(sys.modules, name + suffix, module)
spec = importlib.util.spec_from_file_location(name + '.image_list.runtime', ROOT / 'image_list/runtime.py')
runtime = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, runtime)
spec.loader.exec_module(runtime)
workers = [{'id': 'worker', 'type': 'local'}]
dispatched, uploads = [], []
preflight = AsyncMock(return_value={'worker': True})
sync_media = AsyncMock()
class Response:
status = 200
def __init__(self, data):
self.data = data
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def json(self):
if self.data == 'bad-json':
raise ValueError('old response is not JSON')
return self.data
def raise_for_status(self):
pass
class Session:
def post(self, url, *, data, **kw):
assert url.endswith('/distributed/image-list/item')
body = json.loads(data)
identity = runtime.store_worker_item(body['token'], body['item'])
mode = responses[len(uploads)]
uploads.append(body)
response = {'valid': {'item_identity': identity}, 'absent': {'status': 'stored'},
'invalid': {'item_identity': 'bad'}, 'bad-json': 'bad-json', 'non-object': []}[mode]
return Response(response)
def get(self, url, **kw):
pid = url.rsplit('/', 1)[-1]
return Response({pid: {'status': {'completed': True, 'status_str': 'success'}}})
def delete(self, url, **kw):
runtime.delete_worker_item(url.rsplit('/', 1)[-1])
return Response({})
async def dispatch(worker, prompt, meta, **kwargs):
dispatched.append(copy.deepcopy(prompt))
delivery = prompt['end']['inputs']
token, index = delivery['worker_result_token'], delivery['worker_item_index']
identity = prompt['start']['inputs'].get('worker_item_identity')
options = {'item_identity': identity, 'item_index': index} if identity else {}
data = runtime.consume_worker_item(token, **options)
if identity:
runtime.finish_worker_item(token, identity, index)
runtime.set_master_result(context, index, token, {
'image': data['image'], 'text': str(index), 'values': {'value_3': index}})
assert kwargs['partial_execution_targets'] == ['end']
return f'worker-{index}'
dependencies = {
'.api.orchestration.dispatch': types.SimpleNamespace(
preflight_image_list_workers=preflight, dispatch_worker_prompt=dispatch),
'.api.orchestration.prompt_transform': types.SimpleNamespace(
_resolve_direct_workers_for_image_list=lambda *args: workers),
'.api.orchestration.media_sync': types.SimpleNamespace(sync_worker_media=sync_media),
'.utils.config': types.SimpleNamespace(load_config=lambda: {'settings': {}},
get_worker_timeout_seconds=lambda: 60),
'.utils.network': types.SimpleNamespace(build_master_callback_url=lambda *a, **kw: 'http://master',
build_worker_url=lambda worker, path: 'http://worker' + path,
get_client_session=AsyncMock(return_value=Session())),
'.utils.logging': types.SimpleNamespace(log=lambda *args: None),
}
for suffix, module in dependencies.items():
monkeypatch.setitem(sys.modules, name + suffix, module)
monkeypatch.setitem(sys.modules, 'aiohttp', types.SimpleNamespace(
ClientTimeout=lambda **kwargs: kwargs, ClientError=ConnectionError))
comfy = types.ModuleType('comfy')
comfy.model_management = types.SimpleNamespace(processing_interrupted=lambda: False)
monkeypatch.setitem(sys.modules, 'comfy', comfy)
monkeypatch.setitem(sys.modules, 'comfy.model_management', comfy.model_management)
items = [item(), item()]
items[1]['index'] = 1
context = runtime.create_master_job('start', 'end', graph(), items, ['worker'], '')
state = runtime.get_master_job(context)
try:
result = asyncio.run(runtime._run_image_list_job(state, include_values=True))
assert result[2] == ['0', '1']
assert [row['value_3'] for row in result[3]] == [0, 1]
assert len(uploads) == len(dispatched) == 2
assert sync_media.await_count == 1
for index, prompt in enumerate(dispatched):
start_inputs = prompt['start']['inputs']
if responses[index] == 'valid':
assert start_inputs == {'worker_item_identity': runtime.item_content_identity(items[index])}
assert prompt['end']['inputs']['worker_context_id'] == context
else:
assert 'worker_item_identity' not in start_inputs
assert start_inputs['worker_item_token'] == uploads[index]['token']
assert start_inputs['worker_context_id'] == context
assert 'worker_item_identity' not in prompt['end']['inputs']
assert prompt['end']['inputs']['worker_result_token'] == uploads[index]['token']
assert not runtime._WORKER_ITEMS
finally:
runtime.remove_master_job(context)
runtime._WORKER_ITEMS.clear()
def test_runtime_local_path_hashes_without_transport(monkeypatch):
name = '_native_cache_identity_local_runtime'
for suffix in ('', '.image_list', '.api', '.api.orchestration', '.utils'):
module = types.ModuleType(name + suffix)
module.__path__ = [str(ROOT / suffix.lstrip('.').replace('.', '/'))]
monkeypatch.setitem(sys.modules, name + suffix, module)
spec = importlib.util.spec_from_file_location(name + '.image_list.runtime', ROOT / 'image_list/runtime.py')
runtime = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, runtime)
spec.loader.exec_module(runtime)
dispatched = []
class Slot:
def __init__(self, **kw):
pass
def submit(self, prompt, prompt_id, end_id, workflow_meta):
dispatched.append(prompt)
delivery = prompt[end_id]['inputs']
identity = prompt['start']['inputs']['worker_item_identity']
data = runtime.consume_worker_item(delivery['worker_result_token'], item_identity=identity,
item_index=delivery['worker_item_index'])
runtime.finish_worker_item(delivery['worker_result_token'], identity, data['index'])
runtime.set_master_result(delivery['worker_context_id'], data['index'], delivery['worker_result_token'],
{'image': data['image'], 'values': {'value_3': data['index']}})
future = asyncio.get_running_loop().create_future()
future.set_result(None)
return future
def request_stop(self):
pass
async def close(self):
pass
def forbidden(*args, **kwargs):
raise AssertionError('local path attempted HTTP/transport')
dependencies = {
'.image_list.local_executor': types.SimpleNamespace(LocalExecutionSlot=Slot),
'.api.orchestration.dispatch': types.SimpleNamespace(
preflight_image_list_workers=forbidden, dispatch_worker_prompt=forbidden),
'.api.orchestration.prompt_transform': types.SimpleNamespace(
_resolve_direct_workers_for_image_list=lambda *args: []),
'.utils.config': types.SimpleNamespace(load_config=lambda: {'settings': {}},
get_worker_timeout_seconds=lambda: 60),
'.utils.network': types.SimpleNamespace(build_master_callback_url=forbidden,
build_worker_url=forbidden, get_client_session=forbidden),
'.utils.logging': types.SimpleNamespace(log=lambda *args: None),
}
for suffix, module in dependencies.items():
monkeypatch.setitem(sys.modules, name + suffix, module)
monkeypatch.setitem(sys.modules, 'aiohttp', types.SimpleNamespace(
ClientTimeout=lambda **kwargs: kwargs, ClientError=ConnectionError))
comfy = types.ModuleType('comfy')
comfy.model_management = types.SimpleNamespace(processing_interrupted=lambda: False)
monkeypatch.setitem(sys.modules, 'comfy', comfy)
monkeypatch.setitem(sys.modules, 'comfy.model_management', comfy.model_management)
monkeypatch.setattr(runtime, 'MAX_PAYLOAD_BYTES', 1)
monkeypatch.setattr(runtime, 'encode_item', forbidden)
data = item()
context = runtime.create_master_job('start', 'end', graph(), [data], [], '', master_participates=True)
try:
result = asyncio.run(runtime._run_image_list_job(runtime.get_master_job(context), include_values=True))
assert dispatched[0]['start']['inputs'] == {'worker_item_identity': runtime.item_content_identity(data),
'image_list_local_execution': True}
assert result[3][0]['value_3'] == 0
assert not runtime._WORKER_ITEMS
finally:
runtime.remove_master_job(context)
runtime._WORKER_ITEMS.clear()
def test_real_native_signatures_and_cached_delivery():
core = Path(os.environ.get('COMFYUI_SOURCE_ROOT', str(Path(sys.executable).parents[2])))
if not (core / 'comfy_execution/caching.py').exists():
pytest.skip('real ComfyUI source not available beside this interpreter')
result = subprocess.run([sys.executable, str(ROOT / 'tests/helpers/image_list_native_cache_identity.py'), str(core)],
text=True, capture_output=True, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
assert 'NATIVE_CACHE_IDENTITY_OK' in result.stdout