Skip to content

Commit ab06f87

Browse files
authored
[feat] Support store custom_meta in controller for backend-specific info (#5)
# Goal Add `custom_meta` to provide flexibility for different storage backends so that controller can help to maintain these backend-specific info. Example use cases: 1. ray storage can store object ref 2. yuanrong storage can store device info ```python class TransferQueueStorageKVClient(ABC): @AbstractMethod def put(self, keys: list[str], values: list[Tensor]) -> list[Any]: raise NotImplementedError("Subclasses must implement put") @AbstractMethod def get( self, keys: list[str], shapes=None, dtypes=None, custom_meta=None ) -> list[Any]: raise NotImplementedError("Subclasses must implement get") ``` # Unit tests Unit tests are added to 1. tests/test_controller.py 2. tests/test_controller_data_partitions.py 3. tests/test_metadata.py 4. tests/test_kv_storage_manager.py <img width="1950" height="492" alt="image" src="https://github.com/user-attachments/assets/57cd3a07-3db9-4ebb-b7ab-6440937801b4" /> --------- Signed-off-by: tianyi-ge <tianyig@outlook.com>
1 parent 45cfafe commit ab06f87

10 files changed

Lines changed: 674 additions & 52 deletions

File tree

tests/test_controller_data_partitions.py

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import time
2020
from pathlib import Path
2121

22+
import pytest
23+
2224
parent_dir = Path(__file__).resolve().parent.parent
2325
sys.path.append(str(parent_dir))
2426

@@ -63,6 +65,7 @@ def test_data_partition_status():
6365
1: {"input_ids": (512,), "attention_mask": (512,)},
6466
2: {"input_ids": (512,), "attention_mask": (512,)},
6567
},
68+
custom_meta=None,
6669
)
6770

6871
assert success
@@ -172,6 +175,7 @@ def test_dynamic_expansion_scenarios():
172175
5: {"field_1": (32,)},
173176
10: {"field_1": (32,)},
174177
},
178+
custom_meta=None,
175179
)
176180
assert partition.total_samples_num == 3
177181
assert partition.allocated_samples_num >= 11 # Should accommodate index 10
@@ -180,7 +184,7 @@ def test_dynamic_expansion_scenarios():
180184
# Scenario 2: Adding many fields dynamically
181185
for i in range(15):
182186
partition.update_production_status(
183-
[0], [f"field_{i}"], {0: {f"field_{i}": "torch.bool"}}, {0: {f"field_{i}": (32,)}}
187+
[0], [f"field_{i}"], {0: {f"field_{i}": "torch.bool"}}, {0: {f"field_{i}": (32,)}}, None
184188
)
185189

186190
assert partition.total_fields_num == 16 # Original + 15 new fields
@@ -222,7 +226,7 @@ def test_data_partition_status_advanced():
222226
# Add data to trigger expansion
223227
dtypes = {i: {f"dynamic_field_{s}": "torch.bool" for s in ["a", "b", "c"]} for i in range(5)}
224228
shapes = {i: {f"dynamic_field_{s}": (32,) for s in ["a", "b", "c"]} for i in range(5)}
225-
partition.update_production_status([0, 1, 2, 3, 4], ["field_a", "field_b", "field_c"], dtypes, shapes)
229+
partition.update_production_status([0, 1, 2, 3, 4], ["field_a", "field_b", "field_c"], dtypes, shapes, None)
226230

227231
# Properties should reflect current state
228232
assert partition.total_samples_num >= 5 # At least 5 samples
@@ -253,7 +257,7 @@ def test_data_partition_status_advanced():
253257
11: {"field_d": (32,)},
254258
12: {"field_d": (32,)},
255259
}
256-
partition.update_production_status([10, 11, 12], ["field_d"], dtypes, shapes) # Triggers sample expansion
260+
partition.update_production_status([10, 11, 12], ["field_d"], dtypes, shapes, None) # Triggers sample expansion
257261
expanded_consumption = partition.get_consumption_status(task_name)
258262
assert expanded_consumption[0] == 1 # Preserved
259263
assert expanded_consumption[1] == 1 # Preserved
@@ -265,13 +269,13 @@ def test_data_partition_status_advanced():
265269
# Start with some fields
266270
dtypes = {0: {"initial_field": "torch.bool"}}
267271
shapes = {0: {"field_d": (32,)}}
268-
partition.update_production_status([0], ["initial_field"], dtypes, shapes)
272+
partition.update_production_status([0], ["initial_field"], dtypes, shapes, None)
269273

270274
# Add many fields to trigger column expansion
271275
new_fields = [f"dynamic_field_{i}" for i in range(20)]
272276
dtypes = {1: {f"dynamic_field_{i}": "torch.bool" for i in range(20)}}
273277
shapes = {1: {f"dynamic_field_{i}": (32,) for i in range(20)}}
274-
partition.update_production_status([1], new_fields, dtypes, shapes)
278+
partition.update_production_status([1], new_fields, dtypes, shapes, None)
275279

276280
# Verify all fields are registered and accessible
277281
assert "initial_field" in partition.field_name_mapping
@@ -441,3 +445,84 @@ def test_performance_characteristics():
441445
print("✓ Memory usage patterns reasonable")
442446

443447
print("Performance characteristics tests passed!\n")
448+
449+
450+
def test_custom_meta_in_data_partition_status():
451+
"""Simplified tests for custom_meta functionality in DataPartitionStatus."""
452+
453+
print("Testing simplified custom_meta in DataPartitionStatus...")
454+
455+
from transfer_queue.controller import DataPartitionStatus
456+
457+
partition = DataPartitionStatus(partition_id="custom_meta_test")
458+
459+
# Basic custom_meta storage via update_production_status
460+
global_indices = [0, 1, 2]
461+
field_names = ["input_ids", "attention_mask"]
462+
dtypes = {i: {"input_ids": "torch.int32", "attention_mask": "torch.bool"} for i in global_indices}
463+
shapes = {i: {"input_ids": (512,), "attention_mask": (512,)} for i in global_indices}
464+
custom_meta = {
465+
0: {"input_ids": {"token_count": 100}},
466+
1: {"attention_mask": {"mask_ratio": 0.2}},
467+
2: {"input_ids": {"token_count": 300}},
468+
}
469+
470+
success = partition.update_production_status(
471+
global_indices=global_indices,
472+
field_names=field_names,
473+
dtypes=dtypes,
474+
shapes=shapes,
475+
custom_meta=custom_meta,
476+
)
477+
478+
assert success
479+
480+
# Verify some stored values
481+
assert partition.field_custom_metas[0]["input_ids"]["token_count"] == 100
482+
assert partition.field_custom_metas[1]["attention_mask"]["mask_ratio"] == 0.2
483+
484+
# Retrieval via helper for a subset of fields
485+
retrieved = partition.get_field_custom_meta([0, 1], ["input_ids", "attention_mask"])
486+
assert 0 in retrieved and "input_ids" in retrieved[0]
487+
assert 1 in retrieved and "attention_mask" in retrieved[1]
488+
489+
# Clearing a sample should remove its custom_meta
490+
partition.clear_data([0], clear_consumption=True)
491+
assert 0 not in partition.field_custom_metas
492+
493+
print("✓ Custom_meta tests passed")
494+
495+
496+
def test_update_field_metadata_variants():
497+
"""Test _update_field_metadata handles dtypes/shapes/custom_meta being optional and merging."""
498+
from transfer_queue.controller import DataPartitionStatus
499+
500+
partition = DataPartitionStatus(partition_id="update_meta_test")
501+
502+
# Only dtypes provided
503+
global_indices = [0, 1]
504+
dtypes = {0: {"f1": "torch.int32"}, 1: {"f1": "torch.bool"}}
505+
506+
partition._update_field_metadata(global_indices, dtypes, shapes=None, custom_meta=None)
507+
assert partition.field_dtypes[0]["f1"] == "torch.int32"
508+
assert partition.field_dtypes[1]["f1"] == "torch.bool"
509+
assert partition.field_shapes == {}
510+
assert partition.field_custom_metas == {}
511+
512+
# Only shapes provided for a new index
513+
partition._update_field_metadata([2], dtypes=None, shapes={2: {"f2": (16,)}}, custom_meta=None)
514+
assert partition.field_shapes[2]["f2"] == (16,)
515+
516+
# Only custom_meta provided and merged with existing entries
517+
partition._update_field_metadata([2], dtypes=None, shapes=None, custom_meta={2: {"f2": {"meta": 1}}})
518+
assert 2 in partition.field_custom_metas
519+
assert partition.field_custom_metas[2]["f2"]["meta"] == 1
520+
521+
# Merging dtypes on an existing index should preserve previous keys
522+
partition._update_field_metadata([0], dtypes={0: {"f2": "torch.float32"}}, shapes=None, custom_meta=None)
523+
assert partition.field_dtypes[0]["f1"] == "torch.int32"
524+
assert partition.field_dtypes[0]["f2"] == "torch.float32"
525+
526+
# Length mismatch should raise ValueError when provided mapping lengths differ from global_indices
527+
with pytest.raises(ValueError):
528+
partition._update_field_metadata([0, 1, 2], dtypes={0: {}}, shapes=None, custom_meta=None)

tests/test_kv_storage_manager.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
import asyncio
1617
import sys
1718
import unittest
1819
from pathlib import Path
20+
from unittest.mock import AsyncMock, MagicMock, patch
1921

2022
import torch
2123
from tensordict import TensorDict
@@ -97,6 +99,232 @@ def test_merge_kv_to_tensordict(self):
9799

98100
self.assertEqual(reconstructed.batch_size, torch.Size([3]))
99101

102+
def test_get_shape_type_custom_meta_list_without_custom_meta(self):
103+
"""Test _get_shape_type_custom_meta_list returns correct shapes and dtypes without custom_meta."""
104+
shapes, dtypes, custom_meta_list = KVStorageManager._get_shape_type_custom_meta_list(self.metadata)
105+
106+
# Expected order: sorted by field name (label, mask, text), then by global_index order
107+
# 3 fields * 3 samples = 9 entries
108+
self.assertEqual(len(shapes), 9)
109+
self.assertEqual(len(dtypes), 9)
110+
self.assertEqual(len(custom_meta_list), 9)
111+
112+
# Check shapes - order is label, mask, text (sorted alphabetically)
113+
# label shapes: [()]*3, mask shapes: [(1,)]*3, text shapes: [(2,)]*3
114+
expected_shapes = [
115+
torch.Size([]), # label[0]
116+
torch.Size([]), # label[1]
117+
torch.Size([]), # label[2]
118+
torch.Size([1]), # mask[0]
119+
torch.Size([1]), # mask[1]
120+
torch.Size([1]), # mask[2]
121+
torch.Size([2]), # text[0]
122+
torch.Size([2]), # text[1]
123+
torch.Size([2]), # text[2]
124+
]
125+
self.assertEqual(shapes, expected_shapes)
126+
127+
# All dtypes should be torch.int64
128+
for dtype in dtypes:
129+
self.assertEqual(dtype, torch.int64)
130+
131+
# No custom_meta provided, so all should be None
132+
for meta in custom_meta_list:
133+
self.assertIsNone(meta)
134+
135+
def test_get_shape_type_custom_meta_list_with_custom_meta(self):
136+
"""Test _get_shape_type_custom_meta_list returns correct custom_meta when provided."""
137+
# Add custom_meta to metadata
138+
custom_meta = {
139+
8: {"text": {"key1": "value1"}, "label": {"key2": "value2"}, "mask": {"key3": "value3"}},
140+
9: {"text": {"key4": "value4"}, "label": {"key5": "value5"}, "mask": {"key6": "value6"}},
141+
10: {"text": {"key7": "value7"}, "label": {"key8": "value8"}, "mask": {"key9": "value9"}},
142+
}
143+
self.metadata.update_custom_meta(custom_meta)
144+
145+
shapes, dtypes, custom_meta_list = KVStorageManager._get_shape_type_custom_meta_list(self.metadata)
146+
147+
# Check custom_meta - order is label, mask, text (sorted alphabetically) by global_index
148+
expected_custom_meta = [
149+
{"key2": "value2"}, # label, global_index=8
150+
{"key5": "value5"}, # label, global_index=9
151+
{"key8": "value8"}, # label, global_index=10
152+
{"key3": "value3"}, # mask, global_index=8
153+
{"key6": "value6"}, # mask, global_index=9
154+
{"key9": "value9"}, # mask, global_index=10
155+
{"key1": "value1"}, # text, global_index=8
156+
{"key4": "value4"}, # text, global_index=9
157+
{"key7": "value7"}, # text, global_index=10
158+
]
159+
self.assertEqual(custom_meta_list, expected_custom_meta)
160+
161+
def test_get_shape_type_custom_meta_list_with_partial_custom_meta(self):
162+
"""Test _get_shape_type_custom_meta_list handles partial custom_meta correctly."""
163+
# Add custom_meta only for some global_indexes and fields
164+
custom_meta = {
165+
8: {"text": {"key1": "value1"}}, # Only text field
166+
# global_index 9 has no custom_meta
167+
10: {"label": {"key2": "value2"}, "mask": {"key3": "value3"}}, # label and mask only
168+
}
169+
self.metadata.update_custom_meta(custom_meta)
170+
171+
shapes, dtypes, custom_meta_list = KVStorageManager._get_shape_type_custom_meta_list(self.metadata)
172+
173+
# Check custom_meta - order is label, mask, text (sorted alphabetically) by global_index
174+
expected_custom_meta = [
175+
None, # label, global_index=8 (not in custom_meta)
176+
None, # label, global_index=9 (not in custom_meta)
177+
{"key2": "value2"}, # label, global_index=10
178+
None, # mask, global_index=8 (not in custom_meta)
179+
None, # mask, global_index=9 (not in custom_meta)
180+
{"key3": "value3"}, # mask, global_index=10
181+
{"key1": "value1"}, # text, global_index=8
182+
None, # text, global_index=9 (not in custom_meta)
183+
None, # text, global_index=10 (not in custom_meta for text)
184+
]
185+
self.assertEqual(custom_meta_list, expected_custom_meta)
186+
187+
188+
class TestPutDataWithCustomMeta(unittest.TestCase):
189+
"""Test put_data with custom_meta functionality."""
190+
191+
def setUp(self):
192+
"""Set up test fixtures for put_data tests."""
193+
self.field_names = ["text", "label"]
194+
self.global_indexes = [0, 1, 2]
195+
196+
# Create test data
197+
self.data = TensorDict(
198+
{
199+
"text": torch.tensor([[1, 2], [3, 4], [5, 6]]),
200+
"label": torch.tensor([0, 1, 2]),
201+
},
202+
batch_size=3,
203+
)
204+
205+
# Create metadata without production status set (for insert mode)
206+
samples = []
207+
for sample_id in range(self.data.batch_size[0]):
208+
fields_dict = {}
209+
for field_name in self.data.keys():
210+
tensor = self.data[field_name][sample_id]
211+
field_meta = FieldMeta(name=field_name, dtype=tensor.dtype, shape=tensor.shape, production_status=0)
212+
fields_dict[field_name] = field_meta
213+
sample = SampleMeta(
214+
partition_id="test_partition",
215+
global_index=self.global_indexes[sample_id],
216+
fields=fields_dict,
217+
)
218+
samples.append(sample)
219+
self.metadata = BatchMeta(samples=samples)
220+
221+
@patch.object(KVStorageManager, "_connect_to_controller")
222+
@patch.object(KVStorageManager, "notify_data_update", new_callable=AsyncMock)
223+
def test_put_data_with_custom_meta_from_storage_client(self, mock_notify, mock_connect):
224+
"""Test that put_data correctly processes custom_meta returned by storage client."""
225+
# Create a mock storage client
226+
mock_storage_client = MagicMock()
227+
# Simulate storage client returning custom_meta (one per key)
228+
# Keys order: label[0,1,2], text[0,1,2] (sorted by field name)
229+
mock_custom_meta = [
230+
{"storage_key": "0@label"},
231+
{"storage_key": "1@label"},
232+
{"storage_key": "2@label"},
233+
{"storage_key": "0@text"},
234+
{"storage_key": "1@text"},
235+
{"storage_key": "2@text"},
236+
]
237+
mock_storage_client.put.return_value = mock_custom_meta
238+
239+
# Create manager with mocked dependencies
240+
config = {"client_name": "MockClient"}
241+
with patch(
242+
"transfer_queue.storage.managers.base.StorageClientFactory.create", return_value=mock_storage_client
243+
):
244+
manager = KVStorageManager(config)
245+
246+
# Run put_data
247+
asyncio.run(manager.put_data(self.data, self.metadata))
248+
249+
# Verify storage client was called with correct keys and values
250+
mock_storage_client.put.assert_called_once()
251+
call_args = mock_storage_client.put.call_args
252+
keys = call_args[0][0]
253+
values = call_args[0][1]
254+
255+
# Verify keys are correct
256+
expected_keys = ["0@label", "1@label", "2@label", "0@text", "1@text", "2@text"]
257+
self.assertEqual(keys, expected_keys)
258+
self.assertEqual(len(values), 6)
259+
260+
# Verify notify_data_update was called with correct custom_meta structure
261+
mock_notify.assert_called_once()
262+
notify_call_args = mock_notify.call_args
263+
per_field_custom_meta = notify_call_args[0][5] # 6th positional argument
264+
265+
# Verify custom_meta is structured correctly: {global_index: {field: meta}}
266+
self.assertIn(0, per_field_custom_meta)
267+
self.assertIn(1, per_field_custom_meta)
268+
self.assertIn(2, per_field_custom_meta)
269+
270+
self.assertEqual(per_field_custom_meta[0]["label"], {"storage_key": "0@label"})
271+
self.assertEqual(per_field_custom_meta[0]["text"], {"storage_key": "0@text"})
272+
self.assertEqual(per_field_custom_meta[1]["label"], {"storage_key": "1@label"})
273+
self.assertEqual(per_field_custom_meta[1]["text"], {"storage_key": "1@text"})
274+
self.assertEqual(per_field_custom_meta[2]["label"], {"storage_key": "2@label"})
275+
self.assertEqual(per_field_custom_meta[2]["text"], {"storage_key": "2@text"})
276+
277+
# Verify metadata was updated with custom_meta
278+
all_custom_meta = self.metadata.get_all_custom_meta()
279+
self.assertEqual(all_custom_meta[0]["label"], {"storage_key": "0@label"})
280+
self.assertEqual(all_custom_meta[2]["text"], {"storage_key": "2@text"})
281+
282+
@patch.object(KVStorageManager, "_connect_to_controller")
283+
@patch.object(KVStorageManager, "notify_data_update", new_callable=AsyncMock)
284+
def test_put_data_without_custom_meta(self, mock_notify, mock_connect):
285+
"""Test that put_data works correctly when storage client returns no custom_meta."""
286+
# Create a mock storage client that returns None for custom_meta
287+
mock_storage_client = MagicMock()
288+
mock_storage_client.put.return_value = None
289+
290+
# Create manager with mocked dependencies
291+
config = {"client_name": "MockClient"}
292+
with patch(
293+
"transfer_queue.storage.managers.base.StorageClientFactory.create", return_value=mock_storage_client
294+
):
295+
manager = KVStorageManager(config)
296+
297+
# Run put_data
298+
asyncio.run(manager.put_data(self.data, self.metadata))
299+
300+
# Verify notify_data_update was called with empty dict for custom_meta
301+
mock_notify.assert_called_once()
302+
notify_call_args = mock_notify.call_args
303+
per_field_custom_meta = notify_call_args[0][5] # 6th positional argument
304+
self.assertEqual(per_field_custom_meta, {})
305+
306+
@patch.object(KVStorageManager, "_connect_to_controller")
307+
@patch.object(KVStorageManager, "notify_data_update", new_callable=AsyncMock)
308+
def test_put_data_custom_meta_length_mismatch_raises_error(self, mock_notify, mock_connect):
309+
"""Test that put_data raises ValueError when custom_meta length doesn't match keys."""
310+
# Create a mock storage client that returns mismatched custom_meta length
311+
mock_storage_client = MagicMock()
312+
# Return only 3 custom_meta entries when 6 are expected
313+
mock_storage_client.put.return_value = [{"key": "1"}, {"key": "2"}, {"key": "3"}]
314+
315+
# Create manager with mocked dependencies
316+
config = {"client_name": "MockClient"}
317+
with patch(
318+
"transfer_queue.storage.managers.base.StorageClientFactory.create", return_value=mock_storage_client
319+
):
320+
manager = KVStorageManager(config)
321+
322+
# Run put_data and expect ValueError
323+
with self.assertRaises(ValueError) as context:
324+
asyncio.run(manager.put_data(self.data, self.metadata))
325+
326+
self.assertIn("does not match", str(context.exception))
327+
100328

101329
if __name__ == "__main__":
102330
unittest.main()

0 commit comments

Comments
 (0)