281 lines
9.1 KiB
Python
281 lines
9.1 KiB
Python
import io
|
|
import pickle
|
|
import random
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from mlflow import MlflowException
|
|
from mlflow.utils.async_logging.async_artifacts_logging_queue import AsyncArtifactsLoggingQueue
|
|
|
|
TOTAL_ARTIFACTS = 5
|
|
|
|
|
|
class RunArtifacts:
|
|
def __init__(self, throw_exception_on_artifact_number=None):
|
|
if throw_exception_on_artifact_number is None:
|
|
throw_exception_on_artifact_number = []
|
|
self.received_run_id = ""
|
|
self.received_artifacts = []
|
|
self.received_filenames = []
|
|
self.received_artifact_paths = []
|
|
self.artifact_count = 0
|
|
self.throw_exception_on_artifact_number = throw_exception_on_artifact_number or []
|
|
|
|
def consume_queue_data(self, filename, artifact_path, artifact):
|
|
self.artifact_count += 1
|
|
if self.artifact_count in self.throw_exception_on_artifact_number:
|
|
raise MlflowException("Failed to log run data")
|
|
self.received_artifacts.append(artifact)
|
|
self.received_filenames.append(filename)
|
|
self.received_artifact_paths.append(artifact_path)
|
|
|
|
|
|
def _get_run_artifacts(total_artifacts=TOTAL_ARTIFACTS):
|
|
for num in range(0, total_artifacts):
|
|
filename = f"image_{num}.png"
|
|
artifact_path = f"images/artifact_{num}"
|
|
artifact = Image.new("RGB", (100, 100), color="red")
|
|
yield filename, artifact_path, artifact
|
|
|
|
|
|
def _assert_sent_received_artifacts(
|
|
filenames_sent,
|
|
artifact_paths_sent,
|
|
artifacts_sent,
|
|
received_filenames,
|
|
received_artifact_paths,
|
|
received_artifacts,
|
|
):
|
|
for num in range(1, len(filenames_sent)):
|
|
assert filenames_sent[num] == received_filenames[num]
|
|
|
|
for num in range(1, len(artifact_paths_sent)):
|
|
assert artifact_paths_sent[num] == received_artifact_paths[num]
|
|
|
|
for num in range(1, len(artifacts_sent)):
|
|
assert artifacts_sent[num] == received_artifacts[num]
|
|
|
|
|
|
def test_single_thread_publish_consume_queue():
|
|
run_artifacts = RunArtifacts()
|
|
async_logging_queue = AsyncArtifactsLoggingQueue(run_artifacts.consume_queue_data)
|
|
async_logging_queue.activate()
|
|
filenames_sent = []
|
|
artifact_paths_sent = []
|
|
artifacts_sent = []
|
|
for filename, artifact_path, artifact in _get_run_artifacts():
|
|
async_logging_queue.log_artifacts_async(
|
|
filename=filename, artifact_path=artifact_path, artifact=artifact
|
|
)
|
|
filenames_sent.append(filename)
|
|
artifact_paths_sent.append(artifact_path)
|
|
artifacts_sent.append(artifact)
|
|
async_logging_queue.flush()
|
|
|
|
_assert_sent_received_artifacts(
|
|
filenames_sent,
|
|
artifact_paths_sent,
|
|
artifacts_sent,
|
|
run_artifacts.received_filenames,
|
|
run_artifacts.received_artifact_paths,
|
|
run_artifacts.received_artifacts,
|
|
)
|
|
|
|
|
|
def test_queue_activation():
|
|
run_artifacts = RunArtifacts()
|
|
async_logging_queue = AsyncArtifactsLoggingQueue(run_artifacts.consume_queue_data)
|
|
|
|
assert not async_logging_queue._is_activated
|
|
|
|
for filename, artifact_path, artifact in _get_run_artifacts(1):
|
|
with pytest.raises(MlflowException, match="AsyncArtifactsLoggingQueue is not activated."):
|
|
async_logging_queue.log_artifacts_async(
|
|
filename=filename, artifact_path=artifact_path, artifact=artifact
|
|
)
|
|
|
|
async_logging_queue.activate()
|
|
assert async_logging_queue._is_activated
|
|
|
|
|
|
def test_partial_logging_failed():
|
|
run_data = RunArtifacts(throw_exception_on_artifact_number=[3, 4])
|
|
|
|
async_logging_queue = AsyncArtifactsLoggingQueue(run_data.consume_queue_data)
|
|
async_logging_queue.activate()
|
|
|
|
filenames_sent = []
|
|
artifact_paths_sent = []
|
|
artifacts_sent = []
|
|
|
|
run_operations = []
|
|
batch_id = 1
|
|
for filename, artifact_path, artifact in _get_run_artifacts():
|
|
if batch_id in [3, 4]:
|
|
with pytest.raises(MlflowException, match="Failed to log run data"):
|
|
async_logging_queue.log_artifacts_async(
|
|
filename=filename, artifact_path=artifact_path, artifact=artifact
|
|
).wait()
|
|
else:
|
|
run_operations.append(
|
|
async_logging_queue.log_artifacts_async(
|
|
filename=filename, artifact_path=artifact_path, artifact=artifact
|
|
)
|
|
)
|
|
filenames_sent.append(filename)
|
|
artifact_paths_sent.append(artifact_path)
|
|
artifacts_sent.append(artifact)
|
|
|
|
batch_id += 1
|
|
|
|
for run_operation in run_operations:
|
|
run_operation.wait()
|
|
|
|
_assert_sent_received_artifacts(
|
|
filenames_sent,
|
|
artifact_paths_sent,
|
|
artifacts_sent,
|
|
run_data.received_filenames,
|
|
run_data.received_artifact_paths,
|
|
run_data.received_artifacts,
|
|
)
|
|
|
|
|
|
def test_publish_multithread_consume_single_thread():
|
|
run_data = RunArtifacts(throw_exception_on_artifact_number=[])
|
|
|
|
async_logging_queue = AsyncArtifactsLoggingQueue(run_data.consume_queue_data)
|
|
async_logging_queue.activate()
|
|
|
|
def _send_artifact(run_data_queueing_processor, run_operations=None):
|
|
if run_operations is None:
|
|
run_operations = []
|
|
filenames_sent = []
|
|
artifact_paths_sent = []
|
|
artifacts_sent = []
|
|
|
|
for filename, artifact_path, artifact in _get_run_artifacts():
|
|
run_operations.append(
|
|
run_data_queueing_processor.log_artifacts_async(
|
|
filename=filename, artifact_path=artifact_path, artifact=artifact
|
|
)
|
|
)
|
|
|
|
time.sleep(random.randint(1, 3))
|
|
filenames_sent.append(filename)
|
|
artifact_paths_sent.append(artifact_path)
|
|
artifacts_sent.append(artifact)
|
|
|
|
run_operations = []
|
|
t1 = threading.Thread(
|
|
name="test-async-artifacts-1",
|
|
target=_send_artifact,
|
|
args=(async_logging_queue, run_operations),
|
|
)
|
|
t2 = threading.Thread(
|
|
name="test-async-artifacts-2",
|
|
target=_send_artifact,
|
|
args=(async_logging_queue, run_operations),
|
|
)
|
|
|
|
t1.start()
|
|
t2.start()
|
|
t1.join()
|
|
t2.join()
|
|
|
|
for run_operation in run_operations:
|
|
run_operation.wait()
|
|
|
|
assert len(run_data.received_filenames) == 2 * TOTAL_ARTIFACTS
|
|
assert len(run_data.received_artifact_paths) == 2 * TOTAL_ARTIFACTS
|
|
assert len(run_data.received_artifacts) == 2 * TOTAL_ARTIFACTS
|
|
|
|
|
|
class Consumer:
|
|
def __init__(self) -> None:
|
|
self.filenames = []
|
|
self.artifact_paths = []
|
|
self.artifacts = []
|
|
self.barrier = threading.Event()
|
|
|
|
def consume_queue_data(self, filename, artifact_path, artifact):
|
|
self.barrier.wait()
|
|
self.filenames.append(filename)
|
|
self.artifact_paths.append(artifact_path)
|
|
self.artifacts.append(artifact)
|
|
|
|
def __getstate__(self):
|
|
state = self.__dict__.copy()
|
|
del state["barrier"]
|
|
return state
|
|
|
|
def __setstate__(self, state):
|
|
self.__dict__.update(state)
|
|
self.barrier = threading.Event()
|
|
|
|
|
|
def test_async_logging_queue_pickle():
|
|
consumer = Consumer()
|
|
async_logging_queue = AsyncArtifactsLoggingQueue(consumer.consume_queue_data)
|
|
|
|
# Pickle the queue without activating it.
|
|
buffer = io.BytesIO()
|
|
pickle.dump(async_logging_queue, buffer)
|
|
deserialized_queue = pickle.loads(buffer.getvalue()) # Type: AsyncArtifactsLoggingQueue
|
|
|
|
# Activate the queue and submit 10 items. Workers block on the barrier,
|
|
# so the consumer's state remains empty during pickling.
|
|
async_logging_queue.activate()
|
|
|
|
run_operations = [
|
|
async_logging_queue.log_artifacts_async(
|
|
filename=f"image-{val}.png",
|
|
artifact_path="images/image-artifact.png",
|
|
artifact=Image.new("RGB", (100, 100), color="blue"),
|
|
)
|
|
for val in range(0, 10)
|
|
]
|
|
|
|
# Pickle while workers are blocked — consumer state is deterministically empty.
|
|
buffer = io.BytesIO()
|
|
pickle.dump(async_logging_queue, buffer)
|
|
|
|
deserialized_queue = pickle.loads(buffer.getvalue()) # Type: AsyncLoggingQueue
|
|
assert deserialized_queue._queue.empty()
|
|
assert deserialized_queue._lock is not None
|
|
assert deserialized_queue._is_activated is False
|
|
|
|
# Release workers and wait for all operations to complete.
|
|
consumer.barrier.set()
|
|
|
|
for run_operation in run_operations:
|
|
run_operation.wait()
|
|
|
|
assert len(consumer.filenames) == 10
|
|
|
|
# Activate the deserialized queue and submit 10 more items.
|
|
# The deserialized consumer is a separate copy with an empty filenames list.
|
|
deserialized_consumer = deserialized_queue._artifact_logging_func.__self__
|
|
deserialized_consumer.barrier.set()
|
|
deserialized_queue.activate()
|
|
assert deserialized_queue._is_activated
|
|
|
|
run_operations = []
|
|
|
|
for val in range(0, 10):
|
|
run_operations.append(
|
|
deserialized_queue.log_artifacts_async(
|
|
filename=f"image2-{val}.png",
|
|
artifact_path="images/image-artifact2.png",
|
|
artifact=Image.new("RGB", (100, 100), color="green"),
|
|
)
|
|
)
|
|
|
|
for run_operation in run_operations:
|
|
run_operation.wait()
|
|
|
|
assert len(deserialized_consumer.filenames) == 10
|