Repository navigation
[feat] Support multi-step kv_update with partial values hidden - #190
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 936657f949
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| metadata.partition_ids[0], | ||
| metadata.global_indexes, | ||
| field_schema, | ||
| ready=finalize, |
There was a problem hiding this comment.
Hide fields before writing unfinalized payloads
When finalize=False updates a field that is currently ready, all storage-unit writes have already completed before this notification flips the controller's readiness. A concurrent kv_batch_get in that window can therefore receive ready metadata and read the intermediate payload; if the notification fails, the field can remain visible indefinitely. Hide the cells in the controller before dispatching the write (and account for already in-flight reads), then publish readiness only for a successful finalizing write.
AGENTS.md reference: AGENTS.md:L13-L14
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Thanks. The window is real, but narrow, so I'm leaving the write order as it is in this PR.
- The flow
finalizeis meant for has no window. The firstfinalize=Falsechunk writes a field whose cells are already not ready (new, or still hidden), so nothing becomes visible early. The controller sets the ready bit only on the finalizing write, after every storage unit has committed. - A window only exists in two cases. (a) A caller uses
finalize=Falseto hide a field that is currently ready again. (b) The controller notification fails after storage committed. In case (b) the call raises "Storage update committed, but the controller did not accept its metadata" instead of failing silently. - Hiding first wouldn't close the gap. Readers that already hold ready metadata still read the new value; you point this out yourself ("account for already in-flight reads"). The same exposure exists for any in-place update of a ready field:
finalize=Trueupdates, and the existingkv_putoverwrite onmain. In both, readers can see the new value or an old/new mix across units. A pre-hide RPC would add a round trip and a new state transition but only shrink the window.
Reader isolation for in-place updates would need versioned or leased reads, which is out of scope here. If it becomes a requirement, I'll handle it in a separate PR.
| present = [i for i, idx in enumerate(global_indexes) if idx in stored] | ||
| old_values = self.get_data([field], [global_indexes[i] for i in present])[field] if present else [] | ||
| values = list(new_values) |
There was a problem hiding this comment.
Validate missing-row values against the partition schema
When an update adds field f to a row while f already exists elsewhere in the partition with another tensor dtype, present is empty for this row, so its incoming value is stored as-is and only merged rows are type-checked. After the payload is committed, _build_update_field_schema reports the new dtype and the controller rejects it against FieldMeta, but the controller's readiness assignment has already run; the call raises while the conflicting value can be marked ready under the stale schema. Prevalidate missing-row values against the controller's partition schema before writing them.
AGENTS.md reference: AGENTS.md:L13-L14
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 3891aa4. kv_update now passes include_fields to kv_retrieve_meta, so the metadata carries the column schema for every updated field the partition already knows, including unfinalized fields and fields only some keys hold. The manager sends these column_types to each unit, and StorageUnitData.apply_update checks every new row's tensor/non-tensor kind and dtype before the single put_data. A conflict now fails before anything is stored or marked ready. Covered by test_new_row_must_match_column_type_held_by_other_keys (e2e) and test_apply_update_checks_new_rows_against_column_type_before_writing.
| f"kv_update is not supported by {type(tq_client.storage_manager).__name__}; " | ||
| "it requires the SimpleStorage backend" | ||
| ) | ||
| batch_meta = await tq_client.async_kv_retrieve_meta(keys=keys, partition_id=partition_id, create=True) |
There was a problem hiding this comment.
Reject duplicate keys before creating missing rows
When keys repeats a previously absent key (for example, ['k', 'k']), create=True makes the controller allocate two global indexes and then overwrite keys_mapping['k'] with the second one while retaining both global and reverse mappings. Both rows are written and become eligible for sampling, but only the second remains reachable by key or clear operations, leaking capacity and potentially emitting the orphaned first value; reject duplicate batch keys before registration.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 63a751d. async_kv_batch_update now raises ValueError on repeated keys before any controller or storage call, so nothing is registered or written. This also covers a repeated existing key: there, both merges would read the same old value and one would be silently lost. Covered by test_batch_update_rejects_duplicate_keys_before_registering. kv_batch_put has the same create-path behavior on main, so a controller-side fix would be a separate change.
| "source_index": source_index, | ||
| "fields": rows[key]["fields"], | ||
| # Older dumps lack this key; every field they hold was ready. | ||
| "unfinalized": rows[key].get("unfinalized", []), |
There was a problem hiding this comment.
Bump the dump version for unfinalized readiness
When a dump contains an unfinalized field and is loaded by the immediately previous build, it still advertises DUMP_FORMAT_VERSION = 3, so that reader accepts it but omits unfinalized from load records and publishes every restored field as ready. A downgrade or mixed-version restore therefore silently exposes hidden intermediate data; write a new format version while allowing the current reader to treat legacy v3 dumps as fully finalized.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 3891aa4. DUMP_FORMAT_VERSION is now 4. This build still reads version 3 dumps, which have no unfinalized, so every field restores as ready. Builds that only read version 3 reject a version 4 dump instead of exposing hidden fields. Covered by test_restores_a_version_3_dump_as_ready; docs/data_dump.md is updated.
8b47583 to
376113a
Compare
…nalize kv_update is now a superset of kv_put: a missing key is created, and a field the key does not hold yet is stored as given without calling merge_fn. Merge results are validated against the stored old value, so the update request no longer carries a controller field_schema. finalize=False stores the update but keeps the fields not ready, so samplers, the dataloader and kv_batch_get do not see intermediate values; the update with finalize=True (the default) marks them ready. Selective dump/restore keeps each field's readiness as it was when dumped. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
63b595b to
0cd5af1
Compare
…p dump format - Storage units check rows kv_update adds against the controller's column type, so a dtype conflict fails before anything is stored or marked ready. - kv_batch_get raises when a selected field is not ready in every key instead of returning an empty result. - Add kv_describe / async_kv_describe to list each key's unfinalized fields. - Dump format version 4 records unfinalized fields; version 3 dumps still load. Signed-off-by: Azency <azency_1128@outlook.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
0cd5af1 to
045bdb0
Compare
| raise TypeError( | ||
| f"Merge result for tensor field {field!r} must keep dtype {expected['dtype']}, got {actual}" | ||
| ) | ||
| present = [i for i, idx in enumerate(global_indexes) if idx in stored] |
There was a problem hiding this comment.
[AI Review] kv_update merges into storage values the controller no longer tracks
Problem
StorageUnitData.apply_update chooses between merging and storing from the unit's local state (idx in stored, simple_storage.py#L262-L277). But storage can hold values the controller has already dropped. AsyncSimpleStorageManager.clear_data logs per-unit failures and returns normally (simple_storage_manager.py#L918-L922). The controller then forgets the cell, and for kv_clear(keys) it releases the global index into the pool shared by all partitions.
On main these leftovers are unreachable: kv_update requires keys and fields the controller knows, and kv_put overwrites. With this PR, the next kv_update folds the new value into them:
- Same key:
kv_clear(fields="response")fails in storage.kv_describethen lists onlyprompt, yetkv_update(response=[1])stores[100, 101, 1]. - Another partition:
kv_clearofoldinpafails in storage. The firstkv_updateof a new key inpbreuses that global index and stores[200, 201, 9], i.e. the other partition's payload.
The same rule makes retrying a first chunk unsafe: after a partial multi-unit failure, the units that already committed merge the chunk a second time.
This is separate from the in-place visibility window discussed here: the stored value itself is wrong, and it stays wrong for every later reader.
Repro (SimpleStorage with one storage unit, after tq.init):
from transfer_queue import interface
def concat(old, new):
return torch.cat([old, new])
async def fail_clear(*_args, **_kwargs):
raise RuntimeError("simulated storage unit timeout")
manager = interface._maybe_create_tq_client().storage_manager
real_clear = manager._clear_single_storage_unit
tq.kv_put(key="k", partition_id="p", fields={"response": torch.tensor([100, 101]), "prompt": torch.tensor([7])})
manager._clear_single_storage_unit = fail_clear
tq.kv_clear(keys="k", partition_id="p", fields="response") # returns normally; the failure is only logged
manager._clear_single_storage_unit = real_clear
tq.kv_update(key="k", partition_id="p", fields={"response": torch.tensor([1])}, merge_fn=concat)
tq.kv_batch_get(keys="k", partition_id="p", select_fields="response")["response"][0] # tensor([100, 101, 1])Suggestion: let the controller decide which rows to merge
In async_kv_batch_update, replace kv_retrieve_meta(create=True, include_fields=...) with one controller request specific to kv_update, e.g. kv_prepare_update(keys, partition_id, fields). In a single actor call it registers missing keys and returns the usual BatchMeta. For each updated field it also returns the column type and the batch rows that hold the field. These rows come from FieldMeta.global_indexes, which also covers hidden finalize=False cells.
def kv_prepare_update(self, keys: list[str], partition_id: str, fields: list[str]):
"""Register missing keys and tell kv_update, per field, which rows to merge into."""
metadata = self.kv_retrieve_meta(keys, partition_id, create=True)
partition = self._get_partition(partition_id)
batch_rows = set(metadata.global_indexes)
columns = {}
for name in fields:
meta = partition.field_metadata.get(name)
columns[name] = {
"dtype": meta.dtype if meta else None,
"is_non_tensor": meta.is_non_tensor if meta else None,
"present": sorted(meta.global_indexes & batch_rows) if meta else [],
}
return metadata, columnsThe manager passes each unit its slice of present, and apply_update merges exactly those rows:
held = set(columns[field]["present"])
present = [i for i, idx in enumerate(global_indexes) if idx in held] # was: idx in storedExample:
s0(index 3) holds a hiddenresponse=[1, 2].s1(index 4) holds onlyprompt, but its unit keeps a leftoverresponse=[200, 201].s2is new and is registered as index 7.
For a batch update of response = [3], [5], [9], the controller returns {"response": {"dtype": torch.int64, "is_non_tensor": False, "present": [3]}}. Row 3 merges to [1, 2, 3], row 7 stores [9], and row 4 stores [5] over the leftover instead of [200, 201, 5].
What this changes:
- Leftovers are overwritten, never merged.
- Retrying a first chunk whose write committed but was never recorded by the controller now overwrites instead of merging twice. Retries of later chunks still must not be blind.
- If the controller holds a row that the unit lacks (e.g. it was cleared concurrently), the update fails with
StorageKeyNotFoundErrorinstead of storing the value as new.
Cost:
- No extra RPC: the request replaces the existing
kv_retrieve_metacall. - The controller does one set lookup per row per updated field, which is tens of microseconds for a few thousand rows. The mask is a few KB.
include_fieldsand the manager'scolumn_typesconstruction go away.kv_retrieve_metakeeps its "fields ready in every row" meaning, andBatchMetais unchanged.- The return value is unaffected, because
apply_field_schemaalready adds the updated fields tofield_names.
There was a problem hiding this comment.
Agreed, the unit's local state can't decide what to merge. Fixed in bfdbd7a.
- One controller request instead of
kv_retrieve_meta.kv_batch_updatenow sendsKV_PREPARE_UPDATE, which registers missing keys and returns, per field, the column type and aheldmask aligned with the batch. The mask comes fromFieldMeta.global_indexes, so hiddenfinalize=Falsecells count as held. The manager slices the mask bybatch_positions, like the values. - Units merge exactly the held rows.
apply_updateoverwrites every other row, so leftovers from a failed clear (same key or a reused index from another partition) are overwritten, never merged. A retried first chunk that storage committed but the controller never recorded is rewritten, not merged twice. A held row the unit lacks raisesStorageKeyNotFoundErrorbeforeput_data. - Simpler type check. Every final value is now checked against the controller's column type, which replaces the separate check against the old value.
include_fieldsand the manager'scolumn_typesare gone, sokv_retrieve_metaandBatchMetamatchmainagain.
Tests:
test_storage_values_the_controller_does_not_hold_are_overwritten(e2e): your same-key repro (now[1], was[100, 101, 1]before this commit) and a first-chunk retry after the controller rejects the notification.test_apply_update_overwrites_a_local_value_the_controller_does_not_holdandtest_apply_update_rejects_held_row_missing_locally_before_writing(unit).test_controller_kv_prepare_update_reports_held_rows_from_field_metadata: a hidden cell is held; a new key and a new field are not.
I didn't add the cross-partition case as an e2e test, because which index gets reused depends on the shared pool's order. It's the same code path as the unit-level overwrite test. clear_data swallowing unit failures is unchanged on main; with this fix it only leaks space, so I'll leave it for a separate PR.
There was a problem hiding this comment.
Follow-up in 33ac43d:
- README: the
kv_updateparagraph now says that the controller's record of which rows hold a field (hidden ones included) decides what merges. Every other row overwrites any storage leftover. kv_updatedocstring: says the same thing. It also now lists merge and type-check failures as theRuntimeErrorcallers actually get; it used to sayTypeError.- PR description: Implementation, Compatibility and Tests are updated. Compatibility now notes the new required
columnsargument onTransferQueueClient.(async_)updateandStorageManager.update_data.
There was a problem hiding this comment.
On the root cause: AsyncSimpleStorageManager.clear_data collects per-unit failures with gather(return_exceptions=True) and only logs them. That is pre-existing behavior on main, not something this PR introduces, so it should be fixed in a separate PR against main. This PR doesn't change it.
The two problems are independent:
- This PR: bfdbd7a makes
kv_updatestop trusting the unit's local state, so storage leftovers are overwritten and can no longer corrupt merged values. - The
mainissue: after bfdbd7a, a failed clear only leaks storage space, butkv_clearstill reports success while storage still holds the data. It also affectskv_clearandclear_sampleswithout anykv_updateinvolved.
Fixing that means changing the error contract of clear_data and every caller's handling of it, so it needs its own review.
apply_update chose between merging and storing from the unit's local state, but a unit can keep values the controller already dropped (a clear that failed in storage only logs; kv_clear then frees the global index for any partition). kv_update folded new chunks into those leftovers, and a retried first chunk merged twice on units that had committed. kv_batch_update now sends one KV_PREPARE_UPDATE request instead of kv_retrieve_meta: it registers missing keys and returns, per field, the column type and a `held` mask aligned with the batch, built from FieldMeta.global_indexes (hidden cells included). Units merge exactly the held rows and overwrite the rest; a held row missing on the unit raises before anything is written. Every final value is checked against the column type, replacing the separate old-value check. include_fields and the manager-side column_types go away, so kv_retrieve_meta and BatchMeta match main again. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
4942a21 to
33ac43d
Compare
| keys = [keys] if isinstance(keys, str) else keys | ||
| tq_client = _maybe_create_tq_client() | ||
| description = await tq_client.async_kv_describe(list(dict.fromkeys(keys)), partition_id) | ||
| return description["rows"] |
There was a problem hiding this comment.
[AI Review] Narrow what the public kv_describe returns
async_kv_describe returns the controller's row records unchanged: {"global_index", "fields", "unfinalized", "tag"}.
global_indexis internal. Every other KV API addresses rows by key, and an index is reused across partitions once its key is cleared, so callers should not hold on to it. Once it is public, it is hard to take back.tagduplicateskv_list.unfinalizedis inferred, not recorded. It lists the fields the row holds whoseproduction_statusis 0 (controller.py#L1846-L1850). Butkv_clear(keys)also zeroes the whole row inmark_clearing(controller.py#L1540), before storage is cleared and the metadata is dropped. Akv_describein that window reports every field of the row as unfinalized, and a dump taken in that window restores those rows as hidden. The window is narrow, but it is a reason not to promise more than "fields that are not ready yet".
Suggestion: keep the controller and client shape for the dump, and return only what callers need from the public API. The tests only read fields and unfinalized.
description = await tq_client.async_kv_describe(list(dict.fromkeys(keys)), partition_id)
return {key: {"fields": row["fields"], "unfinalized": row["unfinalized"]} for key, row in description["rows"].items()}There was a problem hiding this comment.
Fixed in 9830d9b. The public kv_describe / async_kv_describe now return {key: {"fields", "unfinalized"}}, and the docstring describes unfinalized only as "the subset that is not ready yet". The dump still reads the full controller rows through client.kv_describe, so it keeps global_index and tag. The e2e test now asserts the whole dict, so an extra key would fail it.
| To build a field over several updates without exposing intermediate values, pass | ||
| `finalize=False` to every `kv_update` except the last one, which uses the default | ||
| `finalize=True`. Until then the field is stored but not ready, so samplers, the | ||
| dataloader and `kv_batch_get` do not see it. |
There was a problem hiding this comment.
[AI Review] Document that chunked updates to one key need a single writer
Readiness follows the last notification the controller receives. When two writers update the same key, storage commits and controller notifications can interleave. A late finalize=False notification then hides a field that the other writer already finalized, and it stays hidden until someone finalizes it again. The chunk order in the merged value also depends on commit order. This paragraph only describes the single-writer flow.
Suggestion: add one sentence, e.g. "Updates to one key must come from one writer at a time; use kv_global_lock if several processes write the same key."
Related: test_unfinalized_updates_stay_hidden_until_finalize asserts that finalize=False hides a field that is already ready (test_kv_interface_e2e.py#L1283-L1287). That is the in-place path that the Known limitation says readers can observe mid-update. Asserting it in a test turns it into a contract. I'd drop that part of the test, or document re-hiding as not guaranteed.
There was a problem hiding this comment.
Fixed in deb3275. The README now says updates to one key must come from one writer at a time, with kv_global_lock for several processes. I dropped the re-hide part of test_unfinalized_updates_stay_hidden_until_finalize and changed the finalize docstring: it no longer says fields "become" hidden, and states that hiding an already ready field is not guaranteed for readers that already hold its metadata. The Known limitation in the PR description now says the same and includes the single-writer rule.
| - **(async_)kv_batch_put**: Put multiple key-value pairs efficiently in batches. | ||
| - **(async_)kv_batch_get**: Retrieve samples (by keys), supporting column selection (by fields). | ||
| - **(async_)kv_update / (async_)kv_batch_update**: Merge new values into already-produced fields with `merge_fn(old, new)`. SimpleStorage only. | ||
| - **(async_)kv_update / (async_)kv_batch_update**: Merge new values into fields with `merge_fn(old, new)`; a superset of put that adds missing keys and fields. SimpleStorage only. |
There was a problem hiding this comment.
[AI Review] kv_update is not a superset of kv_put yet
This line, the kv_update docstring (interface.py#L652) and the PR description call kv_update a superset of kv_put. Three gaps remain:
kv_updatetakes notags.- It takes no
data_parser. - It only runs on SimpleStorage, while
kv_putruns on every backend.
A caller who replaces kv_put with kv_update on that promise loses these.
Being a real superset sounds like a good goal, and it doesn't need to block this PR. The gaps could be closed in follow-up PRs, tracked in one issue so the order is visible: tags first, then data_parser, then the KV backends. Until then, the README and the docstring could name what is still missing, e.g. "a superset of kv_put except tags, data_parser and non-SimpleStorage backends (follow-up: #...)".
There was a problem hiding this comment.
Fixed in 2179afb, by dropping the claim rather than tracking the gaps. The README and the kv_update docstring no longer call it a superset of kv_put. The docstring says it takes no tags or data_parser, and the README entry already says SimpleStorage only. The PR description is updated the same way. If we decide to close the gaps later, that can be its own issue.
| if self.production_status is not None and global_indices and field_names: | ||
| field_indices = [self.field_name_mapping.get(f) for f in field_names] | ||
| self.production_status[torch.tensor(global_indices)[:, None], torch.tensor(field_indices)] = 1 | ||
| self.production_status[torch.tensor(global_indices)[:, None], torch.tensor(field_indices)] = int(ready) |
There was a problem hiding this comment.
[AI Review] Check the dtype before writing readiness (please fix in this PR)
update_production_status writes production_status before _update_field_metadata, and the latter raises on a dtype conflict. The call then returns False, but the cells are already flipped (ready for ready=True, hidden for ready=False), and FieldMeta does not list the row:
p = DataPartitionStatus(partition_id="p")
f32 = {"x": {"dtype": torch.float32, "shape": (2,), "is_nested": False, "is_non_tensor": False}}
i64 = {"x": {"dtype": torch.int64, "shape": (2,), "is_nested": False, "is_non_tensor": False}}
assert p.update_production_status([0], ["x"], f32)
assert not p.update_production_status([1], ["x"], i64)
p.production_status[1, p.field_name_mapping["x"]] # 1: ready, but row 1 is not in FieldMeta["x"]This ordering already exists on main (controller.py#L546-L549 at a55e3dd), and a plain kv_put with another dtype reaches it. This PR's column-type check already protects kv_update whenever the controller knows the column. The remaining cases are concurrent first writes of a new field with different dtypes, and kv_put.
This PR adds the ready=False path through this function, so could you fix it here? Run the dtype check that FieldMeta.update does before anything is written. Then a conflicting write changes nothing, not even the row capacity or the field mapping. For example:
try:
for name, incoming in field_schema.items():
existing = self.field_metadata.get(name)
dtype = incoming.get("dtype")
if existing is not None and None not in (existing.dtype, dtype) and existing.dtype != dtype:
raise ValueError(f"Field {name!r} dtype mismatch: {existing.dtype} != {dtype}")Please don't reuse validate_field_schema here. It also rejects a tensor/non-tensor mix within one column, which puts support: with it, test_dense_field_with_a_wrapped_later_row_roundtrips and test_chunks_with_a_wrapped_last_row_roundtrip in tests/e2e/test_data_dump_e2e.py fail. Please run that file with the fix.
A regression test could put x as float32 on rows 0 and 1, then check that a conflicting int64 write with ready=False to row 1 returns False and leaves row 1 ready.
There was a problem hiding this comment.
Fixed in f0234cb. update_production_status now runs the same dtype check as FieldMeta.update before it touches anything, so a conflicting write returns False and leaves readiness, row capacity, the field mapping and FieldMeta unchanged. It also covers a multi-field schema where a later field conflicts. Tensor/non-tensor mixing stays allowed, so I didn't reuse validate_field_schema; tests/e2e/test_data_dump_e2e.py passes (45/45). Regression test: test_dtype_conflict_leaves_readiness_unchanged puts pv as float32 on rows 0 and 1, then checks that an int64 write with ready=False to row 1 returns False and row 1 stays ready. It fails without the fix.
| metadata: BatchMeta, | ||
| values: TensorDict, | ||
| merge_fn: Callable[[Any, Any], Any], | ||
| columns: dict[str, dict[str, Any]], |
There was a problem hiding this comment.
[AI Review] nit: rename columns to merge_plan
Next to values, which is a TensorDict (that is, the data columns), columns reads like more column data or a list of column names. What it actually carries is the controller's plan for this update:
helddecides which rows go throughmerge_fn.dtypeandis_non_tensorcheck the results.
merge_plan says this at every call site, e.g. apply_update(global_indexes, new_data, merge_fn, merge_plan), and it pairs with kv_prepare_update. held can stay as it is.
The rename is mechanical. It covers the parameters of update, update_data, _update_to_single_storage_unit and apply_update, the "columns" key in the unit request, and the tests.
There was a problem hiding this comment.
Done in 16b2180. columns is now merge_plan in the parameters of update, update_data, _update_to_single_storage_unit and apply_update, in the unit request and the KV_PREPARE_UPDATE response, and in the tests. The PR description's Compatibility section is updated too.
kv_update with partial values hidden
A dtype conflict raised in _update_field_metadata after production_status was already written, so a rejected write still flipped the cells (ready, or hidden for ready=False) for rows FieldMeta does not list. Run the same dtype check up front so a conflicting write changes nothing. Tensor/non-tensor mixing stays allowed, as FieldMeta.update allows it. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
global_index is internal and reused across partitions once a key is cleared, and tag duplicates kv_list. The dump keeps reading the full controller rows through the client. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…ract Readiness follows the last controller notification, so interleaved writers can re-hide a field another writer finalized. finalize=False on an already ready field does not isolate in-flight readers, so the e2e test no longer asserts it and the docstring no longer promises it. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
It takes no tags or data_parser and runs only on SimpleStorage. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
It carries the controller's plan for the update (which rows merge, and the column type to check results against), not column data. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
02_kv_interface put a float32 attention_mask for key 0_0, then int64 masks for the batch. The controller rejected the batch notification on the dtype mismatch, but used to mark its cells ready anyway, so the tutorial passed by accident. With the dtype now checked before readiness is written, input_ids of the batch stayed not ready and Step 7 failed. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Conflicts in README.md and the kv_batch_get docstrings: keep both the data_parser entries from main and this branch's not-ready field error. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
16b2180 to
0255e98
Compare
Conflict in the kv_batch_get docstrings: keep this branch's not-ready field error and main's parser_args wording. kv_batch_update now uses main's _check_unique_keys instead of its own duplicate-key check. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
… kept (#198) ## Problem When a storage unit fails to clear, `AsyncSimpleStorageManager.clear_data` logs it and returns, so `kv_clear`, `clear_samples` and `clear_partition` still release the controller metadata. That is intended: the caller no longer wants the data, and failing its cleanup would stop the job. Since #190 the payloads a unit keeps can no longer be read or merged into a later `kv_update`. They only take memory until their indexes are reused and cleared. The log for such a failure is hard to use. Each failed unit produces two lines, and the second names only the index of the gather task: ```text [TQ_STORAGE_...]: Error clearing storage unit TQ_STORAGE_UNIT_1: unit timeout [TQ_STORAGE_...]: Error in clear operation task 1: unit timeout ``` Neither line says how much data the unit still holds. ## Change `clear_data` logs one line per failed unit, with the unit, the number of samples and the fields it still holds, and the error: ```text [TQ_STORAGE_...]: Failed to clear 2 samples (fields=all) on storage unit TQ_STORAGE_UNIT_1; they stay in its memory: RuntimeError: unit timeout ``` `_clear_single_storage_unit` no longer logs before re-raising, so each failure is logged once. A short comment in `clear_data` records why a failure is logged rather than raised. Behavior is unchanged: a failed unit is still not raised, and every unit is still attempted. ## Tests - `tests/test_async_simple_storage_manager.py::test_clear_data_routes_from_hash` now also fails one of two units. It checks that `clear_data` does not raise, that the other unit is still cleared, and that exactly one log line names the failed unit, its sample count and the error. That part fails without the change. - `python -m compileall -q transfer_queue tutorial tests`, `ruff check` and `ruff format --check` pass. - `python -m pytest -q`: 851 passed, 12 skipped. The 8 errors are in `tests/test_yuanrong_storage_client_e2e.py` and are environment-only; they occur on `main` too. Signed-off-by: 0oshowero0 <o0shower0o@outlook.com> Co-authored-by: Cursor <cursoragent@cursor.com>
## Problem A put could return normally even when the controller failed to record the written data, for example because a field's dtype conflicted with earlier writes in the partition. Storage managers write payloads before notifying the controller: a rejected new key remains unreadable, while an overwrite of an already-ready field can remain visible under stale controller metadata. ## Change - Report controller failures through the existing `REQUEST_ERROR` reply. The controller request loop catches the exception, sends the error, and continues serving requests. - Have the storage manager recognize both ACK and `REQUEST_ERROR` replies. Explicit controller errors raise at the writer immediately; they are no longer ignored until timeout. The error is raised outside the transport exception handler so it reaches the caller. - Keep ACKs to a single `success` flag. `False` means the partition no longer exists, so a late writer only warns. A missing reply still returns `False` from `notify_data_update`, because the controller may yet apply the update. - Preserve `kv_update`'s warning that a committed merge must not be retried blindly, and include the controller's error without depending on its wording. - Correct the put docstrings: failed writes do not roll back stored payloads, and overwrites of ready fields may remain visible. The same client can catch a dtype rejection and put corrected data without restarting the controller or client. A failed put can already have overwritten the previous value; callers must supply the correct replacement data when retrying. ## Validation - `python -m compileall -q transfer_queue tutorial tests` passes. - Ruff lint and format checks pass for the changed files. - Full pytest run with `TQ_TEST_BACKEND=SimpleStorage`: **860 passed, 12 skipped, 8 errors**. The eight errors match the baseline: Yuanrong test fixtures cannot patch `datasystem` when its optional dependency is absent. - Sync and async end-to-end tests verify that a rejected put can be corrected with the same client, including an overwrite that remains readable until corrected. - Regression tests cover immediate propagation of `REQUEST_ERROR`, accepted/missing/cleared-partition ACK behavior, the cleared-partition warning, and the committed-merge error. Mooncake and Yuanrong service integration was not run. Found while reviewing #190. --------- Signed-off-by: 0oshowero0 <o0shower0o@outlook.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Problem
kv_update(#182) merges new values into stored fields, which suits fields built in several steps, such as a response appended chunk by chunk during rollout. But two problems get in the way:StreamingDataLoader/ sampler fetches,kv_batch_getandkv_retrieve_meta, and they can read an intermediate value.kv_put.kv_updaterejects keys or fields that don't exist yet, so callers need a different code path for the first chunk.Summary
finalizeonkv_update/kv_batch_update(and theirasync_variants), defaultTrue. Withfinalize=False, the value is stored and its metadata recorded, but the controller keeps the cells not ready. The field stays hidden from every consumer until an update withfinalize=True.kv_updatecreates missing keys and fields. A field the key doesn't hold yet is stored as given, without callingmerge_fn. Unlikekv_put, it takes notagsordata_parser.kv_putand restore don't takefinalize.unfinalizedfields, and restore keeps them hidden. The dump format becomes version 4; version 3 dumps still load, with every field ready.Usage
Implementation
The path is
async_kv_batch_update→TransferQueueController.kv_prepare_update→TransferQueueClient.async_update→AsyncSimpleStorageManager.update_data→StorageUnitData.apply_updateon each owner unit →notify_data_update(ready=finalize)→DataPartitionStatus.update_production_status(ready=...).readyflag in the update notification setsproduction_statusto 0 or 1. Field metadata (dtype, shapes) is recorded either way. Storage never decides visibility, so the control-plane/data-plane split is unchanged.kv_batch_updatesends oneKV_PREPARE_UPDATErequest in place ofkv_retrieve_meta. It registers missing keys and returns, for each updated field, the column type and aheldmask aligned with the batch. The mask comes fromFieldMeta.global_indexes, so hidden cells count as held. A storage unit may still hold values the controller dropped, e.g. after a failed clear, so it never decides on its own. The manager slices the mask per unit, the same way it slices the values.put_data, so a failure leaves that unit unchanged and nothing is marked ready.kv_retrieve_metaandBatchMetaare unchanged frommain.kv_describereports each row'sunfinalizedfields, and the dump stores them in the row index. On restore, each unit publishes finalized and unfinalized fields in separate updates with the matchingreadyflag.kv_batch_getandkv_batch_get_by_metaraise when a selected field isn't ready in every key; before, they returned an empty TensorDict.kv_describe/async_kv_describereturn{key: {"fields", "unfinalized"}}: each key's fields, and the subset not ready yet.kv_batch_updaterejects duplicate keys before registering anything. A repeated missing key used to orphan rows, and a repeated existing key used to lose a merge.update_production_statuschecks dtypes before writing anything. A conflicting write used to returnFalsewith its cells already flipped (ready, or hidden forready=False) for rowsFieldMetadidn't list.Compatibility
kv_updatecalls keep their behavior, sincefinalizedefaults toTrue. The one change: a missing key or field is now created instead of raising.kv_batch_get/kv_batch_get_by_metawith aselect_fieldsentry that isn't ready in every key now raiseValueErrorinstead of returning an empty result.kv_put. Before, it was rejected but still marked its cells ready.kv_updateremains SimpleStorage-only. KV backends still raiseNotImplementedError.TransferQueueClient.(async_)updateandStorageManager.update_datatake a new requiredmerge_planargument. It comes from the new(async_)kv_prepare_update. The only in-tree caller iskv_batch_update.Known limitation
Updating a field that is already ready happens in place. This covers
finalize=True,kv_putoverwrites, andfinalize=Falseon a ready field, so re-hiding a ready field is not guaranteed. Readers that already hold ready metadata can see the new value, or a mix of old and new across storage units. The normal streaming flow isn't affected, because its fields stay hidden until the final update. See the review thread for why that window is narrow and why hiding first wouldn't close it.Updates to one key must come from one writer at a time: readiness follows the last notification the controller receives, so interleaved writers can re-hide a field the other one finalized. Use
kv_global_lockwhen several processes write the same key.Tests
tests/test_kv_update.py: rows that aren't held are stored withoutmerge_fnand overwrite local leftovers; a held row missing on the unit raises before writing; column-type checks run before writing; duplicate keys are rejected.tests/test_controller.py:kv_prepare_updatecounts hidden cells as held, but not new keys or new fields.tests/test_controller_data_partitions.py: an unfinalized write records metadata but stays hidden from scans; a dtype-conflicting write leaves readiness andFieldMetaunchanged.tests/test_data_dump.pytests/e2e/test_kv_interface_e2e.py,tests/e2e/test_data_dump_e2e.py):kv_batch_geterror andkv_describe;pre-commit run --all-filesandpython -m pytest -qpass, with the Yuanrong e2e test excluded locally because of an environment-specificTORCH_LIBRARYregistration error.