Skip to content

[feat] retire a stream lineage once its successor catches up - #712

Merged
tiankongdeguiji merged 27 commits into
alibaba:masterfrom
tiankongdeguiji:feat/online-learning-handover
Oct 24, 2026
Merged

tiankongdeguiji merged 27 commits into
alibaba:masterfrom
tiankongdeguiji:feat/online-learning-handover

Conversation

@tiankongdeguiji

@tiankongdeguiji tiankongdeguiji commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Supports an online-learning setup where a daily incremental job is the base and a streaming (Kafka) job continues from each daily checkpoint. Each day the streaming job built on day T's checkpoint starts alongside the one built on day T-1. Once the new one has caught up, the old one should retire and EAS should switch over.

  • New env ONLINE_LEARNING_SUCCESSOR_DIR (the next lineage's model_dir). Every log_step_count_steps, all ranks gather their consumed event-times and reconcile them with the same quorum as checkpoint meta data_ts. Rank 0 writes the result to <model_dir>/progress.json, reads the successor's, and broadcasts the decision. When the successor trails by no more than ONLINE_LEARNING_HANDOVER_TOLERANCE_SECS (default 300 s), all ranks leave both training loops at the same step.
  • On hand-over the epoch-end and final checkpoints are skipped, because a retired lineage is never resumed. The trailing delta dump, upload drain and online dense finalize_publish still run. Other termination paths are unchanged.
  • At startup it fails fast if the successor model_dir equals this run's own, or if the tolerance is not a finite non-negative number. Both are checked on every rank, so a bad value can't strand the other ranks.
  • tzrec.export gains --edit_config_json (same semantics as train_eval / predict), so the full export can set the per-lineage FeatureDB version without a config placeholder.
  • Version bumped to 1.5.6.
  • New best-practice doc docs/source/best_practices/online_learning.md. It covers the DataWorks DAG (train_daily -> full_export -> train_stream -> deploy) with dlc submit node commands, a timeline diagram, per-lineage isolation (Kafka group.id, FeatureDB version, model and serving dirs), and the PyODPS 3 deploy node (eascmd modify).

Design choices

  • Progress signal: a small progress.json in each lineage's model_dir, independent of online dense export and of the dataset type (any source that sets the data timestamp). An earlier revision compared the published dense_hot_export/current.json. That tied hand-over to ONLINE_DENSE_EXPORT=1, and its value only moved once per publish interval.
  • Where it lives: OnlineLearningHandover and create_online_learning_handover in tzrec/utils/online_learning_util.py (progress file, quorum, tolerance, env parsing). main.py only creates it, calls update_and_check() at the log cadence, and acts on the result by exiting both loops and running the existing shutdown. This matches how DeltaEmbeddingDumper / OnlineDenseExportManager are wired. Neither the checkpoint nor the export manager is involved. A source without timestamps never hands over, and rank 0 warns once.
  • Catch-up is approximate on purpose: quorum consumed freshness, with no per-partition offset comparison or final-watermark handshake. With dense export on, the successor's served version can trail its consumption by about one publish interval at the switch, which is accepted.
  • Tolerance: once both lineages are at the Kafka head they consume at the same rate. The successor's value can then trail by one check interval plus partition skew, so a strict >= could fail indefinitely. That would keep the old job running until its max runtime and block every later deploy. A fixed 300 s default covers this, because progress now updates every check rather than every publish. Rank 0 logs the observed lag at each check.
  • Switch trigger: the predecessor retires itself, and the DataWorks deploy node depends on the previous cycle's stream node, so no extra polling job is needed. Deploy does not repeat the freshness comparison. Stream jobs therefore use num_epochs: 1 and no num_steps, since any normal exit triggers deploy. A self-dependency on the deploy node keeps switches in date order.

The deploy template's Processor keys were verified against the TorchRecProcessor source. The service mounts the daily full export as the model dir and the lineage serving root separately, and sets dense_hot_load.current_json explicitly, because the default looks under the model dir. It sets feature_store_embedding_polling.{feature_view_name, version: ol_<bizdate>}, which defaults to apply mode, and distributed_embedding: true.

Test Plan

  • python -m unittest tzrec.utils.online_learning_util_test tzrec.main_test: 15 + 38 tests pass. New cases cover:
    • the decision: ahead, equal, behind, within/beyond tolerance; successor progress missing, corrupt or non-object; timestamps that are -1, NaN, inf, non-numeric or bool; own progress content
    • no event-time: no progress written, no hand-over, a single warning
    • setup: no successor -> disabled; no online export needed; default 300 s; env override and its validation; identical model_dir rejected
    • a mocked train loop: caught_up is called at the log cadence with the step and batch timestamp, and hand-over skips the epoch-end and final saves while dump, drain and finalize still run
    • tzrec.export forwarding --edit_config_json
  • Mutation check: removing the outer-loop break makes the retirement test fail on the epoch-end save.
  • 2-rank torchrun on 2x A10 (NCCL), ranks with different timestamps:
    • the quorum value (900 with quorum 1.0) is written by rank 0 only, with no temp file left behind;
    • no hand-over while the successor's progress is absent;
    • both ranks hand over once it trails by 50 s with a 60 s tolerance.
  • pre-commit run, pyrefly check: clean.
  • Not run: an end-to-end streaming run against a live Kafka.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH

tiankongdeguiji and others added 21 commits October 23, 2026 21:21
Add ONLINE_LEARNING_SUCCESSOR_DIR so a daily-based streaming job ends
itself when the next day's lineage has published data at least as fresh,
letting DataWorks deploy the successor after the predecessor exits. Adds
a best-practice doc for the DataWorks + PAI-DLC + PAI-EAS workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH
Lets the online-learning full export set the per-lineage FeatureDB version
from the command line instead of a placeholder in the config file; the doc
also renames the stream/export nodes to train_stream/full_export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH
The training-side hand-over already checks successor freshness, so the
deploy node no longer repeats the comparison, which could block the switch
when the predecessor publishes once more while finalizing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH
…ghtly in DLC nodes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH
…over

Once both lineages consume at the Kafka head, each one's published
data_timestamp only advances at its own publish, so with a fixed phase
offset between the two publish cadences the strict successor >= own
comparison could never pass, leaving the old job running until its max
runtime and blocking every later deploy. The check now accepts a
successor trailing by up to one publish interval plus a 5-minute margin
(15 minutes when the cadence is step-based), overridable via
ONLINE_LEARNING_HANDOVER_TOLERANCE_SECS, and logs the observed lag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH
Lineage hand-over no longer requires online dense export. Each lineage
writes its quorum consumed event-time to <model_dir>/progress.json at the
log cadence, and the predecessor retires once the successor trails by at
most ONLINE_LEARNING_HANDOVER_TOLERANCE_SECS (default 300s);
ONLINE_LEARNING_SUCCESSOR_DIR now names the successor's model_dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH
@tiankongdeguiji tiankongdeguiji added the claude-review Let Claude Review label Oct 23, 2026
@github-actions github-actions Bot removed the claude-review Let Claude Review label Oct 23, 2026
data_ts = _valid_data_ts(
checkpoint_util.quorum_event_time(worker_ts, self._quorum)
)
decision = [False]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These object collectives run on the default process group, which is NCCL on GPU training. all_gather_object/broadcast_object_list then pickle to CPU tensors, copy them H2D/D2H through torch.cuda.current_device(), and force a device sync in the train loop — on the same communicator the forward/backward collectives use. The sibling collectives in this same loop deliberately avoid that: CheckpointManager gathers event-times on a dedicated gloo group (checkpoint_util.py:463) and OnlineDenseExportManager does the same (online_dense_export_util.py:359), and dist_util.get_dist_object_pg() exists for exactly this. Worth reusing one of those patterns here (e.g. create a gloo side group in __init__ and pass group= to both calls). Amortized cost is small at the default log_step_count_steps, but it scales linearly if the log cadence is lowered.

class OnlineLearningHandover:
"""Retire a stream lineage once the next one has consumed data as fresh.

Every lineage writes its quorum consumed event-time to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: a JSON integer too large for a float (e.g. a 400-digit data_ts written by hand or by a foreign writer on the shared mount) makes math.isfinite(ts) raise OverflowError (it converts the int to float), which is not caught by except (OSError, ValueError) in _read_successor_ts — so instead of degrading to "no hand-over" like the other corrupt-file cases, rank 0 dies between the gather and the broadcast and the job goes down with a confusing traceback. Converting inside a try: float(ts) except (TypeError, ValueError, OverflowError): return None (or adding OverflowError to the read's except tuple) closes it in one line.

def setUp(self):
self.test_dir = make_test_dir()
self.addCleanup(shutil.rmtree, self.test_dir)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice coverage of the decision logic and corrupt-file cases. One gap worth closing: the distributed branch of update_and_check (dist.is_initialized() path — worker_ts sizing, all_gather_object, and broadcast_object_list) and the rank≠0 side of the RANK gate are never executed by these tests, and that's the code whose failure mode is a deadlock or rank desync in real multi-worker jobs. Both are cheap to cover without GPUs by mocking dist.is_initialized/get_world_size/all_gather_object/broadcast_object_list (e.g. an all_gather_object side_effect filling [1000.0, -1.0] and asserting rank 0 writes the quorum value; a broadcast_object_list side_effect setting the decision and asserting a RANK=1 process returns the broadcast result without touching the filesystem). Non-blocking suggestion.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

Reviewed across code quality, performance, test coverage, documentation accuracy, and robustness. Overall this is a well-built feature: the hand-over decision is broadcast so all ranks exit the loops together (keeping the every-step collectives matched), the quorum math is reused from checkpoint_util.quorum_event_time, the progress write is atomic (tmp + os.replace), env config fails fast on every rank, and the doc was verified against the implementation with no discrepancies found (env names, 300 s default, cadence, checkpoint-skip and restart semantics all check out).

Findings (inline, all non-blocking):

  1. Object collectives on the default NCCL group — all_gather_object / broadcast_object_list run on the default process group instead of a gloo side group like the sibling collectives in the same loop (checkpoint_util, online_dense_export_util), adding H2D/D2H copies and a device sync to the train loop.
  2. OverflowError on a huge-int data_ts in the successor's progress.json — the one corrupt-file shape that escapes the OSError, ValueError handling and crashes rank 0 mid-collective instead of degrading to "no hand-over"; one-line fix.
  3. Distributed branch has no test coverage — the dist path of update_and_check and the rank≠0 side are never executed by the tests; both are cheap to cover with mocked dist.

Two smaller observations not worth inline threads: rank 0 does two remote-filesystem round trips per check (write own progress + open a usually-absent successor file) while the other ranks block in the broadcast — fine at the default log_step_count_steps, worth remembering if the cadence is ever lowered; and a non-numeric ONLINE_LEARNING_HANDOVER_TOLERANCE_SECS value raises Python's generic could not convert string to float without naming the env var, unlike the crafted message for the negative/NaN cases.

🤖 Generated with Claude Code

eric-gecheng
eric-gecheng previously approved these changes Oct 24, 2026
@eric-gecheng
eric-gecheng dismissed their stale review October 24, 2026 06:42

changed needed

FeatureDB now holds only each lineage's streaming deltas, which EAS
applies on top of the daily full export it loads from OSS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FouSwvnwPn2Z61mLnv4vsH

### full_export

将天级模型的 embedding 全量上传到本链路的 FeatureDB 版本,详见[dynemb 流式导出与 FeatureDB 全量上传](../usage/export.md)。天级配置中 `export_config.dynamic_embedding.feature_store_config` 不写 `version`,导出时通过 `--edit_config_json` 按业务日期指定:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

full_export 只需要导出到oss即可, 不需要将embedding 上传featureDB, 只有增量训练才需要上传featureDB


deploy 使用 PyODPS 3 节点,调度资源组选择 `dataworks_pairec_task_pod`(其中预置了 `eascmd64`),调度参数同上,节点内通过 `args` 读取。

服务挂载两个 OSS 目录:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

只要挂载一个oss 目录, 就是T 天的全量导出目录。
增量训练 stream_T会直接在此目录中创建dense_hot_export/current.json

},
"dense_hot_load": {
"enable": True,
"current_json": "/mnt/serving/dense_hot_export/current.json",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

无需指定current_json 字段。


在线学习相关的 Processor 参数:

- `distributed_embedding`:需为 `true`,增量 embedding 轮询依赖分布式 embedding。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processor 无需配置distributed_embedding, processor 会自动判断。

在线学习相关的 Processor 参数:

- `distributed_embedding`:需为 `true`,增量 embedding 轮询依赖分布式 embedding。
- `feature_store_embedding_polling`:从 FeatureDB 轮询增量 embedding。配置该块且不写 `mode` 时即为 `apply` 模式:服务启动时先从头扫描版本 `version` 中的全部数据,再增量更新。`feature_view_name` 与 `version` 必填,且需与训练写入的 FeatureView 和版本一致;同时需要 `access_key_id`、`access_key_secret`、`region`、`fs_project`、`featuredb_username`、`featuredb_password`。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

改为:

  • feature_store_embedding_polling: 从FeatureDB 轮询增量embedding的配置。 必填项为feature_view_name和version, 且需与训练时写入的feature view和version一致。 选填参数sparse_poll_interval_secs, 不填时默认为 0,表示由processor按同频率轮询dense和sparse, 若设置>0, 则可以以更高的频率拉取embedding并更新。


- `distributed_embedding`:需为 `true`,增量 embedding 轮询依赖分布式 embedding。
- `feature_store_embedding_polling`:从 FeatureDB 轮询增量 embedding。配置该块且不写 `mode` 时即为 `apply` 模式:服务启动时先从头扫描版本 `version` 中的全部数据,再增量更新。`feature_view_name` 与 `version` 必填,且需与训练写入的 FeatureView 和版本一致;同时需要 `access_key_id`、`access_key_secret`、`region`、`fs_project`、`featuredb_username`、`featuredb_password`。
- `sparse_poll_interval_secs`(位于 `feature_store_embedding_polling` 内):默认 0,即不做周期扫描,只在加载新的 dense 版本前拉取一次增量,sparse 随 dense 按 `paired_dump_interval_*` 的节奏更新。若训练配置了更细的 `sparse_dump_interval_*`,希望线上 embedding 更快生效,需将其设为正数(秒)。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

改为:

  • sparse_poll_interval_secs(位于 feature_store_embedding_polling 内):默认 0,即不做周期扫描,只在加载新的 dense 版本同时拉取一次增量embedding。 拉取的频率由dense_hot_load.dense_poll_interval_minutes控制。 若配置sparse_poll_interval_secs>0, 则可以让embedding更快的生效。


此外,全量导出与流式训练的产物需要相互兼容:

- 两者需使用相同的模型配置与导出相关环境变量(如 `INPUT_TILE`)。Processor 会比较热加载版本与初始模型的 `dense_meta.json`。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

如 INPUT_TILE, 这个可以删除, 因为input_tile 在distributed export 下默认就是 3。

"model_config": {
"outputs": "<输出名,逗号分隔>",
"fg_mode": "normal",
"distributed_embedding": True,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"distributed_embedding": True,

这个无需配置

"feature_view_name": "<与训练 feature_store_config 相同的 feature_view_name>",
"version": f"ol_{bizdate}",
},
"dense_hot_load": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dense_hot_load 里可以增加配置这个配置作为示例:

"dense_poll_interval_minutes": 1,

--pipeline_config_path /mnt/data/online/stream.config \
--continue_train \
--fine_tune_checkpoint /mnt/data/online/daily/${bizdate} \
--model_dir /mnt/data/online/stream/${bizdate}/model \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

增量训练的model_dir 应该配置成T 日的模型全量导出目录

tiankongdeguiji and others added 5 commits October 24, 2026 19:05
… online learning doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fSGWxBXJPmjjvt7LVaXNp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fSGWxBXJPmjjvt7LVaXNp
@tiankongdeguiji
tiankongdeguiji merged commit 61f6233 into alibaba:master Oct 24, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants