diff --git a/core/proto/swanlab/metric/data/v1/data.pb.go b/core/proto/swanlab/metric/data/v1/data.pb.go index cb1dd4b92..2d0d8849f 100644 --- a/core/proto/swanlab/metric/data/v1/data.pb.go +++ b/core/proto/swanlab/metric/data/v1/data.pb.go @@ -116,11 +116,13 @@ func (x *MediaValue) GetItems() []*MediaItem { // 单条媒体的文件引用与元数据。 type MediaItem struct { - state protoimpl.MessageState `protogen:"open.v1"` - Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"` - Sha256 string `protobuf:"bytes,2,opt,name=sha256,proto3" json:"sha256,omitempty"` - Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` // 文件大小(bytes) - Caption string `protobuf:"bytes,4,opt,name=caption,proto3" json:"caption,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"` + Sha256 string `protobuf:"bytes,2,opt,name=sha256,proto3" json:"sha256,omitempty"` + Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` // 文件大小(bytes) + Caption string `protobuf:"bytes,4,opt,name=caption,proto3" json:"caption,omitempty"` + // 媒体内容,仅 online + skip_store=true 时写入 + Payload []byte `protobuf:"bytes,5,opt,name=payload,proto3,oneof" json:"payload,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -183,6 +185,13 @@ func (x *MediaItem) GetCaption() string { return "" } +func (x *MediaItem) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + type ScalarRecord struct { state protoimpl.MessageState `protogen:"open.v1"` // 记录的 key,如 "train/loss" @@ -354,12 +363,15 @@ const file_swanlab_metric_data_v1_data_proto_rawDesc = "" + "\x06number\x18\x01 \x01(\x01R\x06number\"E\n" + "\n" + "MediaValue\x127\n" + - "\x05items\x18\x01 \x03(\v2!.swanlab.metric.data.v1.MediaItemR\x05items\"m\n" + + "\x05items\x18\x01 \x03(\v2!.swanlab.metric.data.v1.MediaItemR\x05items\"\x98\x01\n" + "\tMediaItem\x12\x1a\n" + "\bfilename\x18\x01 \x01(\tR\bfilename\x12\x16\n" + "\x06sha256\x18\x02 \x01(\tR\x06sha256\x12\x12\n" + "\x04size\x18\x03 \x01(\x03R\x04size\x12\x18\n" + - "\acaption\x18\x04 \x01(\tR\acaption\"\xe3\x01\n" + + "\acaption\x18\x04 \x01(\tR\acaption\x12\x1d\n" + + "\apayload\x18\x05 \x01(\fH\x00R\apayload\x88\x01\x01B\n" + + "\n" + + "\b_payload\"\xe3\x01\n" + "\fScalarRecord\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x12\n" + "\x04step\x18\x02 \x01(\x03R\x04step\x128\n" + @@ -415,6 +427,7 @@ func file_swanlab_metric_data_v1_data_proto_init() { if File_swanlab_metric_data_v1_data_proto != nil { return } + file_swanlab_metric_data_v1_data_proto_msgTypes[2].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/core/proto/swanlab/save/v1/save.pb.go b/core/proto/swanlab/save/v1/save.pb.go index aed069426..7c89315bc 100644 --- a/core/proto/swanlab/save/v1/save.pb.go +++ b/core/proto/swanlab/save/v1/save.pb.go @@ -126,8 +126,12 @@ func (SaveType) EnumDescriptor() ([]byte, []int) { } // 文件保存记录,由 swanlab.save() API 产生。 -// 每条记录对应一个用户文件的保存操作。 // size/md5/mime_type 不在此处存储,由 Core 在持久化/上传时按需计算。 +// +// payload 仅用于 online + skip_store 下的四类内部 save +// (CONFIG/METADATA/REQUIREMENTS/CONDA):此时 source_path/target_path 为空, +// Core 直接从 payload 解析并上传;CUSTOM 的 payload 必须为空,只使用 source_path; +// 未启用 skip_store 时 payload 为空,Core 按 source_path 处理。 type SaveRecord struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // 文件标识(相对于 base_path 的路径) @@ -135,6 +139,7 @@ type SaveRecord struct { TargetPath string `protobuf:"bytes,3,opt,name=target_path,json=targetPath,proto3" json:"target_path,omitempty"` // 本地镜像目标路径(软链接位置,由 Core 填充) Policy SavePolicy `protobuf:"varint,4,opt,name=policy,proto3,enum=swanlab.save.v1.SavePolicy" json:"policy,omitempty"` // 保存策略 Type SaveType `protobuf:"varint,5,opt,name=type,proto3,enum=swanlab.save.v1.SaveType" json:"type,omitempty"` // 文件类型 + Payload []byte `protobuf:"bytes,6,opt,name=payload,proto3,oneof" json:"payload,omitempty"` // 内部 metadata 元文件内容,skip_store 设置下有效 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -204,11 +209,18 @@ func (x *SaveRecord) GetType() SaveType { return SaveType_SAVE_TYPE_CUSTOM } +func (x *SaveRecord) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + var File_swanlab_save_v1_save_proto protoreflect.FileDescriptor const file_swanlab_save_v1_save_proto_rawDesc = "" + "\n" + - "\x1aswanlab/save/v1/save.proto\x12\x0fswanlab.save.v1\"\xc6\x01\n" + + "\x1aswanlab/save/v1/save.proto\x12\x0fswanlab.save.v1\"\xf1\x01\n" + "\n" + "SaveRecord\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1f\n" + @@ -217,7 +229,10 @@ const file_swanlab_save_v1_save_proto_rawDesc = "" + "\vtarget_path\x18\x03 \x01(\tR\n" + "targetPath\x123\n" + "\x06policy\x18\x04 \x01(\x0e2\x1b.swanlab.save.v1.SavePolicyR\x06policy\x12-\n" + - "\x04type\x18\x05 \x01(\x0e2\x19.swanlab.save.v1.SaveTypeR\x04type*L\n" + + "\x04type\x18\x05 \x01(\x0e2\x19.swanlab.save.v1.SaveTypeR\x04type\x12\x1d\n" + + "\apayload\x18\x06 \x01(\fH\x00R\apayload\x88\x01\x01B\n" + + "\n" + + "\b_payload*L\n" + "\n" + "SavePolicy\x12\x13\n" + "\x0fSAVE_POLICY_NOW\x10\x00\x12\x13\n" + @@ -264,6 +279,7 @@ func file_swanlab_save_v1_save_proto_init() { if File_swanlab_save_v1_save_proto != nil { return } + file_swanlab_save_v1_save_proto_msgTypes[0].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/core/proto/swanlab/settings/core/v1/core.pb.go b/core/proto/swanlab/settings/core/v1/core.pb.go index c9949ea9f..87e0cc435 100644 --- a/core/proto/swanlab/settings/core/v1/core.pb.go +++ b/core/proto/swanlab/settings/core/v1/core.pb.go @@ -33,8 +33,10 @@ type CoreSettings struct { SaveSplit int64 `protobuf:"varint,7,opt,name=save_split,json=saveSplit,proto3" json:"save_split,omitempty"` SavePart int64 `protobuf:"varint,8,opt,name=save_part,json=savePart,proto3" json:"save_part,omitempty"` SaveBatch int32 `protobuf:"varint,9,opt,name=save_batch,json=saveBatch,proto3" json:"save_batch,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Skip the local Record store (run-*.swanlab). Only valid in online mode. + SkipStore bool `protobuf:"varint,10,opt,name=skip_store,json=skipStore,proto3" json:"skip_store,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CoreSettings) Reset() { @@ -130,11 +132,18 @@ func (x *CoreSettings) GetSaveBatch() int32 { return 0 } +func (x *CoreSettings) GetSkipStore() bool { + if x != nil { + return x.SkipStore + } + return false +} + var File_swanlab_settings_core_v1_core_proto protoreflect.FileDescriptor const file_swanlab_settings_core_v1_core_proto_rawDesc = "" + "\n" + - "#swanlab/settings/core/v1/core.proto\x12\x18swanlab.settings.core.v1\"\xa5\x02\n" + + "#swanlab/settings/core/v1/core.proto\x12\x18swanlab.settings.core.v1\"\xc4\x02\n" + "\fCoreSettings\x12\x15\n" + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12\x17\n" + "\arun_dir\x18\x02 \x01(\tR\x06runDir\x12!\n" + @@ -146,7 +155,10 @@ const file_swanlab_settings_core_v1_core_proto_rawDesc = "" + "save_split\x18\a \x01(\x03R\tsaveSplit\x12\x1b\n" + "\tsave_part\x18\b \x01(\x03R\bsavePart\x12\x1d\n" + "\n" + - "save_batch\x18\t \x01(\x05R\tsaveBatchBPZNgithub.com/swanhubx/swanlab/core/proto/swanlab/settings/core/v1;settingscorev1b\x06proto3" + "save_batch\x18\t \x01(\x05R\tsaveBatch\x12\x1d\n" + + "\n" + + "skip_store\x18\n" + + " \x01(\bR\tskipStoreBPZNgithub.com/swanhubx/swanlab/core/proto/swanlab/settings/core/v1;settingscorev1b\x06proto3" var ( file_swanlab_settings_core_v1_core_proto_rawDescOnce sync.Once diff --git a/core/proto/swanlab/settings/probe/v1/probe.pb.go b/core/proto/swanlab/settings/probe/v1/probe.pb.go index ea377420a..6c7885834 100644 --- a/core/proto/swanlab/settings/probe/v1/probe.pb.go +++ b/core/proto/swanlab/settings/probe/v1/probe.pb.go @@ -24,7 +24,7 @@ const ( type ProbeSettings struct { state protoimpl.MessageState `protogen:"open.v1"` RunId *string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3,oneof" json:"run_id,omitempty"` // probe 侧需要校验 run_id 是否存在,不存在拒绝上报 - RunDir *string `protobuf:"bytes,2,opt,name=run_dir,json=runDir,proto3,oneof" json:"run_dir,omitempty"` // probe 侧需要校验 run_dir 是否存在,不存在拒绝上报 + RunDir *string `protobuf:"bytes,2,opt,name=run_dir,json=runDir,proto3,oneof" json:"run_dir,omitempty"` // 运行目录 GlobalSystemStep *int64 `protobuf:"varint,3,opt,name=global_system_step,json=globalSystemStep,proto3,oneof" json:"global_system_step,omitempty"` // probe 侧需要校验 global_system_step 是否存在,不存在拒绝上报 Hardware bool `protobuf:"varint,4,opt,name=hardware,proto3" json:"hardware,omitempty"` Runtime bool `protobuf:"varint,5,opt,name=runtime,proto3" json:"runtime,omitempty"` @@ -35,6 +35,7 @@ type ProbeSettings struct { Monitor bool `protobuf:"varint,10,opt,name=monitor,proto3" json:"monitor,omitempty"` MonitorInterval int32 `protobuf:"varint,11,opt,name=monitor_interval,json=monitorInterval,proto3" json:"monitor_interval,omitempty"` MonitorDiskDir string `protobuf:"bytes,12,opt,name=monitor_disk_dir,json=monitorDiskDir,proto3" json:"monitor_disk_dir,omitempty"` + SkipStore bool `protobuf:"varint,13,opt,name=skip_store,json=skipStore,proto3" json:"skip_store,omitempty"` // 跳过探针本地文件落盘,内容直接内联 SaveRecord.payload(仅 online 合法) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -153,11 +154,18 @@ func (x *ProbeSettings) GetMonitorDiskDir() string { return "" } +func (x *ProbeSettings) GetSkipStore() bool { + if x != nil { + return x.SkipStore + } + return false +} + var File_swanlab_settings_probe_v1_probe_proto protoreflect.FileDescriptor const file_swanlab_settings_probe_v1_probe_proto_rawDesc = "" + "\n" + - "%swanlab/settings/probe/v1/probe.proto\x12\x19swanlab.settings.probe.v1\"\xb5\x03\n" + + "%swanlab/settings/probe/v1/probe.proto\x12\x19swanlab.settings.probe.v1\"\xd4\x03\n" + "\rProbeSettings\x12\x1a\n" + "\x06run_id\x18\x01 \x01(\tH\x00R\x05runId\x88\x01\x01\x12\x1c\n" + "\arun_dir\x18\x02 \x01(\tH\x01R\x06runDir\x88\x01\x01\x121\n" + @@ -171,7 +179,9 @@ const file_swanlab_settings_probe_v1_probe_proto_rawDesc = "" + "\amonitor\x18\n" + " \x01(\bR\amonitor\x12)\n" + "\x10monitor_interval\x18\v \x01(\x05R\x0fmonitorInterval\x12(\n" + - "\x10monitor_disk_dir\x18\f \x01(\tR\x0emonitorDiskDirB\t\n" + + "\x10monitor_disk_dir\x18\f \x01(\tR\x0emonitorDiskDir\x12\x1d\n" + + "\n" + + "skip_store\x18\r \x01(\bR\tskipStoreB\t\n" + "\a_run_idB\n" + "\n" + "\b_run_dirB\x15\n" + diff --git a/protos/swanlab/metric/data/v1/data.proto b/protos/swanlab/metric/data/v1/data.proto index 70d8f91d6..d8dc2e7a1 100644 --- a/protos/swanlab/metric/data/v1/data.proto +++ b/protos/swanlab/metric/data/v1/data.proto @@ -24,6 +24,8 @@ message MediaItem { string sha256 = 2; int64 size = 3; // 文件大小(bytes) string caption = 4; + // 媒体内容,仅 online + skip_store=true 时写入 + optional bytes payload = 5; } message ScalarRecord { diff --git a/protos/swanlab/save/v1/save.proto b/protos/swanlab/save/v1/save.proto index cb0c7fc23..60816b427 100644 --- a/protos/swanlab/save/v1/save.proto +++ b/protos/swanlab/save/v1/save.proto @@ -19,12 +19,17 @@ enum SaveType { } // 文件保存记录,由 swanlab.save() API 产生。 -// 每条记录对应一个用户文件的保存操作。 // size/md5/mime_type 不在此处存储,由 Core 在持久化/上传时按需计算。 +// +// payload 仅用于 online + skip_store 下的四类内部 save +// (CONFIG/METADATA/REQUIREMENTS/CONDA):此时 source_path/target_path 为空, +// Core 直接从 payload 解析并上传;CUSTOM 的 payload 必须为空,只使用 source_path; +// 未启用 skip_store 时 payload 为空,Core 按 source_path 处理。 message SaveRecord { string name = 1; // 文件标识(相对于 base_path 的路径) string source_path = 2; // 源文件绝对路径 string target_path = 3; // 本地镜像目标路径(软链接位置,由 Core 填充) SavePolicy policy = 4; // 保存策略 SaveType type = 5; // 文件类型 + optional bytes payload = 6; // 内部 metadata 元文件内容,skip_store 设置下有效 } diff --git a/protos/swanlab/settings/core/v1/core.proto b/protos/swanlab/settings/core/v1/core.proto index 1de9c1ba5..5a4dc77f5 100644 --- a/protos/swanlab/settings/core/v1/core.proto +++ b/protos/swanlab/settings/core/v1/core.proto @@ -15,4 +15,6 @@ message CoreSettings { int64 save_split = 7; int64 save_part = 8; int32 save_batch = 9; + // Skip the local Record store (run-*.swanlab). Only valid in online mode. + bool skip_store = 10; } diff --git a/protos/swanlab/settings/probe/v1/probe.proto b/protos/swanlab/settings/probe/v1/probe.proto index 7f7104a1b..ad1c276a6 100644 --- a/protos/swanlab/settings/probe/v1/probe.proto +++ b/protos/swanlab/settings/probe/v1/probe.proto @@ -6,7 +6,7 @@ option go_package = "github.com/swanhubx/swanlab/core/proto/swanlab/settings/pro message ProbeSettings { optional string run_id = 1; // probe 侧需要校验 run_id 是否存在,不存在拒绝上报 - optional string run_dir = 2; // probe 侧需要校验 run_dir 是否存在,不存在拒绝上报 + optional string run_dir = 2; // 运行目录 optional int64 global_system_step = 3; // probe 侧需要校验 global_system_step 是否存在,不存在拒绝上报 bool hardware = 4; bool runtime = 5; @@ -17,4 +17,5 @@ message ProbeSettings { bool monitor = 10; int32 monitor_interval = 11; string monitor_disk_dir = 12; + bool skip_store = 13; // 跳过探针本地文件落盘,内容直接内联 SaveRecord.payload(仅 online 合法) } diff --git a/swanlab/proto/swanlab/metric/data/v1/data_pb2.py b/swanlab/proto/swanlab/metric/data/v1/data_pb2.py index 8fcb9035e..de39d02f8 100644 --- a/swanlab/proto/swanlab/metric/data/v1/data_pb2.py +++ b/swanlab/proto/swanlab/metric/data/v1/data_pb2.py @@ -26,7 +26,7 @@ from swanlab.proto.swanlab.metric.column.v1 import column_pb2 as swanlab_dot_metric_dot_column_dot_v1_dot_column__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!swanlab/metric/data/v1/data.proto\x12\x16swanlab.metric.data.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a%swanlab/metric/column/v1/column.proto\"\x1d\n\x0bScalarValue\x12\x0e\n\x06number\x18\x01 \x01(\x01\">\n\nMediaValue\x12\x30\n\x05items\x18\x01 \x03(\x0b\x32!.swanlab.metric.data.v1.MediaItem\"L\n\tMediaItem\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x0e\n\x06sha256\x18\x02 \x01(\t\x12\x0c\n\x04size\x18\x03 \x01(\x03\x12\x0f\n\x07\x63\x61ption\x18\x04 \x01(\t\"\xc0\x01\n\x0cScalarRecord\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x0c\n\x04step\x18\x02 \x01(\x03\x12\x32\n\x04type\x18\x03 \x01(\x0e\x32$.swanlab.metric.column.v1.ColumnType\x12-\n\ttimestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x32\n\x05value\x18\x05 \x01(\x0b\x32#.swanlab.metric.data.v1.ScalarValue\"\xbe\x01\n\x0bMediaRecord\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x0c\n\x04step\x18\x02 \x01(\x03\x12\x32\n\x04type\x18\x03 \x01(\x0e\x32$.swanlab.metric.column.v1.ColumnType\x12-\n\ttimestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x31\n\x05value\x18\x05 \x01(\x0b\x32\".swanlab.metric.data.v1.MediaValueBFZDgithub.com/swanhubx/swanlab/core/proto/swanlab/metric/data/v1;datav1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!swanlab/metric/data/v1/data.proto\x12\x16swanlab.metric.data.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a%swanlab/metric/column/v1/column.proto\"\x1d\n\x0bScalarValue\x12\x0e\n\x06number\x18\x01 \x01(\x01\">\n\nMediaValue\x12\x30\n\x05items\x18\x01 \x03(\x0b\x32!.swanlab.metric.data.v1.MediaItem\"n\n\tMediaItem\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x0e\n\x06sha256\x18\x02 \x01(\t\x12\x0c\n\x04size\x18\x03 \x01(\x03\x12\x0f\n\x07\x63\x61ption\x18\x04 \x01(\t\x12\x14\n\x07payload\x18\x05 \x01(\x0cH\x00\x88\x01\x01\x42\n\n\x08_payload\"\xc0\x01\n\x0cScalarRecord\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x0c\n\x04step\x18\x02 \x01(\x03\x12\x32\n\x04type\x18\x03 \x01(\x0e\x32$.swanlab.metric.column.v1.ColumnType\x12-\n\ttimestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x32\n\x05value\x18\x05 \x01(\x0b\x32#.swanlab.metric.data.v1.ScalarValue\"\xbe\x01\n\x0bMediaRecord\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x0c\n\x04step\x18\x02 \x01(\x03\x12\x32\n\x04type\x18\x03 \x01(\x0e\x32$.swanlab.metric.column.v1.ColumnType\x12-\n\ttimestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x31\n\x05value\x18\x05 \x01(\x0b\x32\".swanlab.metric.data.v1.MediaValueBFZDgithub.com/swanhubx/swanlab/core/proto/swanlab/metric/data/v1;datav1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -39,9 +39,9 @@ _globals['_MEDIAVALUE']._serialized_start=164 _globals['_MEDIAVALUE']._serialized_end=226 _globals['_MEDIAITEM']._serialized_start=228 - _globals['_MEDIAITEM']._serialized_end=304 - _globals['_SCALARRECORD']._serialized_start=307 - _globals['_SCALARRECORD']._serialized_end=499 - _globals['_MEDIARECORD']._serialized_start=502 - _globals['_MEDIARECORD']._serialized_end=692 + _globals['_MEDIAITEM']._serialized_end=338 + _globals['_SCALARRECORD']._serialized_start=341 + _globals['_SCALARRECORD']._serialized_end=533 + _globals['_MEDIARECORD']._serialized_start=536 + _globals['_MEDIARECORD']._serialized_end=726 # @@protoc_insertion_point(module_scope) diff --git a/swanlab/proto/swanlab/metric/data/v1/data_pb2.pyi b/swanlab/proto/swanlab/metric/data/v1/data_pb2.pyi index 04cdb3a92..773a91691 100644 --- a/swanlab/proto/swanlab/metric/data/v1/data_pb2.pyi +++ b/swanlab/proto/swanlab/metric/data/v1/data_pb2.pyi @@ -23,16 +23,18 @@ class MediaValue(_message.Message): def __init__(self, items: _Optional[_Iterable[_Union[MediaItem, _Mapping]]] = ...) -> None: ... class MediaItem(_message.Message): - __slots__ = ("filename", "sha256", "size", "caption") + __slots__ = ("filename", "sha256", "size", "caption", "payload") FILENAME_FIELD_NUMBER: _ClassVar[int] SHA256_FIELD_NUMBER: _ClassVar[int] SIZE_FIELD_NUMBER: _ClassVar[int] CAPTION_FIELD_NUMBER: _ClassVar[int] + PAYLOAD_FIELD_NUMBER: _ClassVar[int] filename: str sha256: str size: int caption: str - def __init__(self, filename: _Optional[str] = ..., sha256: _Optional[str] = ..., size: _Optional[int] = ..., caption: _Optional[str] = ...) -> None: ... + payload: bytes + def __init__(self, filename: _Optional[str] = ..., sha256: _Optional[str] = ..., size: _Optional[int] = ..., caption: _Optional[str] = ..., payload: _Optional[bytes] = ...) -> None: ... class ScalarRecord(_message.Message): __slots__ = ("key", "step", "type", "timestamp", "value") diff --git a/swanlab/proto/swanlab/save/v1/save_pb2.py b/swanlab/proto/swanlab/save/v1/save_pb2.py index cbc73ed84..d08827ba6 100644 --- a/swanlab/proto/swanlab/save/v1/save_pb2.py +++ b/swanlab/proto/swanlab/save/v1/save_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1aswanlab/save/v1/save.proto\x12\x0fswanlab.save.v1\"\x9a\x01\n\nSaveRecord\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0bsource_path\x18\x02 \x01(\t\x12\x13\n\x0btarget_path\x18\x03 \x01(\t\x12+\n\x06policy\x18\x04 \x01(\x0e\x32\x1b.swanlab.save.v1.SavePolicy\x12\'\n\x04type\x18\x05 \x01(\x0e\x32\x19.swanlab.save.v1.SaveType*L\n\nSavePolicy\x12\x13\n\x0fSAVE_POLICY_NOW\x10\x00\x12\x13\n\x0fSAVE_POLICY_END\x10\x01\x12\x14\n\x10SAVE_POLICY_LIVE\x10\x02*\x7f\n\x08SaveType\x12\x14\n\x10SAVE_TYPE_CUSTOM\x10\x00\x12\x14\n\x10SAVE_TYPE_CONFIG\x10\x01\x12\x16\n\x12SAVE_TYPE_METADATA\x10\x02\x12\x1a\n\x16SAVE_TYPE_REQUIREMENTS\x10\x03\x12\x13\n\x0fSAVE_TYPE_CONDA\x10\x04\x42?Z=github.com/swanhubx/swanlab/core/proto/swanlab/save/v1;savev1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1aswanlab/save/v1/save.proto\x12\x0fswanlab.save.v1\"\xbc\x01\n\nSaveRecord\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0bsource_path\x18\x02 \x01(\t\x12\x13\n\x0btarget_path\x18\x03 \x01(\t\x12+\n\x06policy\x18\x04 \x01(\x0e\x32\x1b.swanlab.save.v1.SavePolicy\x12\'\n\x04type\x18\x05 \x01(\x0e\x32\x19.swanlab.save.v1.SaveType\x12\x14\n\x07payload\x18\x06 \x01(\x0cH\x00\x88\x01\x01\x42\n\n\x08_payload*L\n\nSavePolicy\x12\x13\n\x0fSAVE_POLICY_NOW\x10\x00\x12\x13\n\x0fSAVE_POLICY_END\x10\x01\x12\x14\n\x10SAVE_POLICY_LIVE\x10\x02*\x7f\n\x08SaveType\x12\x14\n\x10SAVE_TYPE_CUSTOM\x10\x00\x12\x14\n\x10SAVE_TYPE_CONFIG\x10\x01\x12\x16\n\x12SAVE_TYPE_METADATA\x10\x02\x12\x1a\n\x16SAVE_TYPE_REQUIREMENTS\x10\x03\x12\x13\n\x0fSAVE_TYPE_CONDA\x10\x04\x42?Z=github.com/swanhubx/swanlab/core/proto/swanlab/save/v1;savev1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -32,10 +32,10 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'Z=github.com/swanhubx/swanlab/core/proto/swanlab/save/v1;savev1' - _globals['_SAVEPOLICY']._serialized_start=204 - _globals['_SAVEPOLICY']._serialized_end=280 - _globals['_SAVETYPE']._serialized_start=282 - _globals['_SAVETYPE']._serialized_end=409 + _globals['_SAVEPOLICY']._serialized_start=238 + _globals['_SAVEPOLICY']._serialized_end=314 + _globals['_SAVETYPE']._serialized_start=316 + _globals['_SAVETYPE']._serialized_end=443 _globals['_SAVERECORD']._serialized_start=48 - _globals['_SAVERECORD']._serialized_end=202 + _globals['_SAVERECORD']._serialized_end=236 # @@protoc_insertion_point(module_scope) diff --git a/swanlab/proto/swanlab/save/v1/save_pb2.pyi b/swanlab/proto/swanlab/save/v1/save_pb2.pyi index 3eed018fd..000ecaa5b 100644 --- a/swanlab/proto/swanlab/save/v1/save_pb2.pyi +++ b/swanlab/proto/swanlab/save/v1/save_pb2.pyi @@ -28,15 +28,17 @@ SAVE_TYPE_REQUIREMENTS: SaveType SAVE_TYPE_CONDA: SaveType class SaveRecord(_message.Message): - __slots__ = ("name", "source_path", "target_path", "policy", "type") + __slots__ = ("name", "source_path", "target_path", "policy", "type", "payload") NAME_FIELD_NUMBER: _ClassVar[int] SOURCE_PATH_FIELD_NUMBER: _ClassVar[int] TARGET_PATH_FIELD_NUMBER: _ClassVar[int] POLICY_FIELD_NUMBER: _ClassVar[int] TYPE_FIELD_NUMBER: _ClassVar[int] + PAYLOAD_FIELD_NUMBER: _ClassVar[int] name: str source_path: str target_path: str policy: SavePolicy type: SaveType - def __init__(self, name: _Optional[str] = ..., source_path: _Optional[str] = ..., target_path: _Optional[str] = ..., policy: _Optional[_Union[SavePolicy, str]] = ..., type: _Optional[_Union[SaveType, str]] = ...) -> None: ... + payload: bytes + def __init__(self, name: _Optional[str] = ..., source_path: _Optional[str] = ..., target_path: _Optional[str] = ..., policy: _Optional[_Union[SavePolicy, str]] = ..., type: _Optional[_Union[SaveType, str]] = ..., payload: _Optional[bytes] = ...) -> None: ... diff --git a/swanlab/proto/swanlab/settings/core/v1/core_pb2.py b/swanlab/proto/swanlab/settings/core/v1/core_pb2.py index 02a33cc0b..58ba99678 100644 --- a/swanlab/proto/swanlab/settings/core/v1/core_pb2.py +++ b/swanlab/proto/swanlab/settings/core/v1/core_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#swanlab/settings/core/v1/core.proto\x12\x18swanlab.settings.core.v1\"\xc2\x01\n\x0c\x43oreSettings\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x0f\n\x07run_dir\x18\x02 \x01(\t\x12\x14\n\x0csection_rule\x18\x03 \x01(\x05\x12\x14\n\x0crecord_batch\x18\x04 \x01(\x05\x12\x17\n\x0frecord_interval\x18\x05 \x01(\x01\x12\x11\n\tsave_size\x18\x06 \x01(\x03\x12\x12\n\nsave_split\x18\x07 \x01(\x03\x12\x11\n\tsave_part\x18\x08 \x01(\x03\x12\x12\n\nsave_batch\x18\t \x01(\x05\x42PZNgithub.com/swanhubx/swanlab/core/proto/swanlab/settings/core/v1;settingscorev1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#swanlab/settings/core/v1/core.proto\x12\x18swanlab.settings.core.v1\"\xd6\x01\n\x0c\x43oreSettings\x12\x0e\n\x06run_id\x18\x01 \x01(\t\x12\x0f\n\x07run_dir\x18\x02 \x01(\t\x12\x14\n\x0csection_rule\x18\x03 \x01(\x05\x12\x14\n\x0crecord_batch\x18\x04 \x01(\x05\x12\x17\n\x0frecord_interval\x18\x05 \x01(\x01\x12\x11\n\tsave_size\x18\x06 \x01(\x03\x12\x12\n\nsave_split\x18\x07 \x01(\x03\x12\x11\n\tsave_part\x18\x08 \x01(\x03\x12\x12\n\nsave_batch\x18\t \x01(\x05\x12\x12\n\nskip_store\x18\n \x01(\x08\x42PZNgithub.com/swanhubx/swanlab/core/proto/swanlab/settings/core/v1;settingscorev1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -33,5 +33,5 @@ _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'ZNgithub.com/swanhubx/swanlab/core/proto/swanlab/settings/core/v1;settingscorev1' _globals['_CORESETTINGS']._serialized_start=66 - _globals['_CORESETTINGS']._serialized_end=260 + _globals['_CORESETTINGS']._serialized_end=280 # @@protoc_insertion_point(module_scope) diff --git a/swanlab/proto/swanlab/settings/core/v1/core_pb2.pyi b/swanlab/proto/swanlab/settings/core/v1/core_pb2.pyi index 719054648..994b618c8 100644 --- a/swanlab/proto/swanlab/settings/core/v1/core_pb2.pyi +++ b/swanlab/proto/swanlab/settings/core/v1/core_pb2.pyi @@ -5,7 +5,7 @@ from typing import ClassVar as _ClassVar, Optional as _Optional DESCRIPTOR: _descriptor.FileDescriptor class CoreSettings(_message.Message): - __slots__ = ("run_id", "run_dir", "section_rule", "record_batch", "record_interval", "save_size", "save_split", "save_part", "save_batch") + __slots__ = ("run_id", "run_dir", "section_rule", "record_batch", "record_interval", "save_size", "save_split", "save_part", "save_batch", "skip_store") RUN_ID_FIELD_NUMBER: _ClassVar[int] RUN_DIR_FIELD_NUMBER: _ClassVar[int] SECTION_RULE_FIELD_NUMBER: _ClassVar[int] @@ -15,6 +15,7 @@ class CoreSettings(_message.Message): SAVE_SPLIT_FIELD_NUMBER: _ClassVar[int] SAVE_PART_FIELD_NUMBER: _ClassVar[int] SAVE_BATCH_FIELD_NUMBER: _ClassVar[int] + SKIP_STORE_FIELD_NUMBER: _ClassVar[int] run_id: str run_dir: str section_rule: int @@ -24,4 +25,5 @@ class CoreSettings(_message.Message): save_split: int save_part: int save_batch: int - def __init__(self, run_id: _Optional[str] = ..., run_dir: _Optional[str] = ..., section_rule: _Optional[int] = ..., record_batch: _Optional[int] = ..., record_interval: _Optional[float] = ..., save_size: _Optional[int] = ..., save_split: _Optional[int] = ..., save_part: _Optional[int] = ..., save_batch: _Optional[int] = ...) -> None: ... + skip_store: bool + def __init__(self, run_id: _Optional[str] = ..., run_dir: _Optional[str] = ..., section_rule: _Optional[int] = ..., record_batch: _Optional[int] = ..., record_interval: _Optional[float] = ..., save_size: _Optional[int] = ..., save_split: _Optional[int] = ..., save_part: _Optional[int] = ..., save_batch: _Optional[int] = ..., skip_store: bool = ...) -> None: ... diff --git a/swanlab/proto/swanlab/settings/probe/v1/probe_pb2.py b/swanlab/proto/swanlab/settings/probe/v1/probe_pb2.py index 4aa9f90c3..a783a427d 100644 --- a/swanlab/proto/swanlab/settings/probe/v1/probe_pb2.py +++ b/swanlab/proto/swanlab/settings/probe/v1/probe_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%swanlab/settings/probe/v1/probe.proto\x12\x19swanlab.settings.probe.v1\"\xb4\x02\n\rProbeSettings\x12\x13\n\x06run_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07run_dir\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x1f\n\x12global_system_step\x18\x03 \x01(\x03H\x02\x88\x01\x01\x12\x10\n\x08hardware\x18\x04 \x01(\x08\x12\x0f\n\x07runtime\x18\x05 \x01(\x08\x12\x14\n\x0crequirements\x18\x06 \x01(\x08\x12\r\n\x05\x63onda\x18\x07 \x01(\x08\x12\x0b\n\x03git\x18\x08 \x01(\x08\x12\x0f\n\x07swanlab\x18\t \x01(\x08\x12\x0f\n\x07monitor\x18\n \x01(\x08\x12\x18\n\x10monitor_interval\x18\x0b \x01(\x05\x12\x18\n\x10monitor_disk_dir\x18\x0c \x01(\tB\t\n\x07_run_idB\n\n\x08_run_dirB\x15\n\x13_global_system_stepBRZPgithub.com/swanhubx/swanlab/core/proto/swanlab/settings/probe/v1;settingsprobev1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%swanlab/settings/probe/v1/probe.proto\x12\x19swanlab.settings.probe.v1\"\xc8\x02\n\rProbeSettings\x12\x13\n\x06run_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07run_dir\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x1f\n\x12global_system_step\x18\x03 \x01(\x03H\x02\x88\x01\x01\x12\x10\n\x08hardware\x18\x04 \x01(\x08\x12\x0f\n\x07runtime\x18\x05 \x01(\x08\x12\x14\n\x0crequirements\x18\x06 \x01(\x08\x12\r\n\x05\x63onda\x18\x07 \x01(\x08\x12\x0b\n\x03git\x18\x08 \x01(\x08\x12\x0f\n\x07swanlab\x18\t \x01(\x08\x12\x0f\n\x07monitor\x18\n \x01(\x08\x12\x18\n\x10monitor_interval\x18\x0b \x01(\x05\x12\x18\n\x10monitor_disk_dir\x18\x0c \x01(\t\x12\x12\n\nskip_store\x18\r \x01(\x08\x42\t\n\x07_run_idB\n\n\x08_run_dirB\x15\n\x13_global_system_stepBRZPgithub.com/swanhubx/swanlab/core/proto/swanlab/settings/probe/v1;settingsprobev1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -33,5 +33,5 @@ _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'ZPgithub.com/swanhubx/swanlab/core/proto/swanlab/settings/probe/v1;settingsprobev1' _globals['_PROBESETTINGS']._serialized_start=69 - _globals['_PROBESETTINGS']._serialized_end=377 + _globals['_PROBESETTINGS']._serialized_end=397 # @@protoc_insertion_point(module_scope) diff --git a/swanlab/proto/swanlab/settings/probe/v1/probe_pb2.pyi b/swanlab/proto/swanlab/settings/probe/v1/probe_pb2.pyi index ca5753fff..6c32440f5 100644 --- a/swanlab/proto/swanlab/settings/probe/v1/probe_pb2.pyi +++ b/swanlab/proto/swanlab/settings/probe/v1/probe_pb2.pyi @@ -5,7 +5,7 @@ from typing import ClassVar as _ClassVar, Optional as _Optional DESCRIPTOR: _descriptor.FileDescriptor class ProbeSettings(_message.Message): - __slots__ = ("run_id", "run_dir", "global_system_step", "hardware", "runtime", "requirements", "conda", "git", "swanlab", "monitor", "monitor_interval", "monitor_disk_dir") + __slots__ = ("run_id", "run_dir", "global_system_step", "hardware", "runtime", "requirements", "conda", "git", "swanlab", "monitor", "monitor_interval", "monitor_disk_dir", "skip_store") RUN_ID_FIELD_NUMBER: _ClassVar[int] RUN_DIR_FIELD_NUMBER: _ClassVar[int] GLOBAL_SYSTEM_STEP_FIELD_NUMBER: _ClassVar[int] @@ -18,6 +18,7 @@ class ProbeSettings(_message.Message): MONITOR_FIELD_NUMBER: _ClassVar[int] MONITOR_INTERVAL_FIELD_NUMBER: _ClassVar[int] MONITOR_DISK_DIR_FIELD_NUMBER: _ClassVar[int] + SKIP_STORE_FIELD_NUMBER: _ClassVar[int] run_id: str run_dir: str global_system_step: int @@ -30,4 +31,5 @@ class ProbeSettings(_message.Message): monitor: bool monitor_interval: int monitor_disk_dir: str - def __init__(self, run_id: _Optional[str] = ..., run_dir: _Optional[str] = ..., global_system_step: _Optional[int] = ..., hardware: bool = ..., runtime: bool = ..., requirements: bool = ..., conda: bool = ..., git: bool = ..., swanlab: bool = ..., monitor: bool = ..., monitor_interval: _Optional[int] = ..., monitor_disk_dir: _Optional[str] = ...) -> None: ... + skip_store: bool + def __init__(self, run_id: _Optional[str] = ..., run_dir: _Optional[str] = ..., global_system_step: _Optional[int] = ..., hardware: bool = ..., runtime: bool = ..., requirements: bool = ..., conda: bool = ..., git: bool = ..., swanlab: bool = ..., monitor: bool = ..., monitor_interval: _Optional[int] = ..., monitor_disk_dir: _Optional[str] = ..., skip_store: bool = ...) -> None: ... diff --git a/swanlab/sdk/cmd/init.py b/swanlab/sdk/cmd/init.py index 2d382cde2..7750b6b08 100644 --- a/swanlab/sdk/cmd/init.py +++ b/swanlab/sdk/cmd/init.py @@ -226,7 +226,14 @@ def init( # ---------------------------------- 再次确认参数 ---------------------------------- # 根据交互式引导确定最终的模式 mode = prompt_init_mode(run_settings) - run_settings.merge_settings({"mode": mode}) + if run_settings.core.skip_store and mode != "online": + # 交互式引导允许用户从 online 降级到 offline;skip_store 仅 online 合法, + # 此处显式关闭并告警,避免 merge_settings 抛出裸 ValidationError, + # 同时保证离线数据正常落盘(否则与 “Results will be saved locally” 提示矛盾)。 + console.warning("core.skip_store is only supported in online mode; it has been disabled for this run.") + run_settings.merge_settings({"mode": mode, "core": {"skip_store": False}}) + else: + run_settings.merge_settings({"mode": mode}) # 校验 run id 与 resume,仅在对两者存在性有要求的模式下校验 if run_settings.mode == "online": if run_settings.run.resume == "must": @@ -538,6 +545,7 @@ def _init(run_settings: Settings, callbacks: Optional[CallbacksType]) -> Tuple[R 上下文生命周期通过 `Run` 管理,而非全局 `ContextVar` """ mode = run_settings.mode + skip_store = run_settings.core.skip_store # 实验路径,/:username/:project_name/:slug(run_id),与open api命名一致 path = None # 1. 生成run_id @@ -546,7 +554,7 @@ def _init(run_settings: Settings, callbacks: Optional[CallbacksType]) -> Tuple[R run_id = run_settings.run.id assert run_id, "Run id is not provided." # 2. 创建运行目录 - if mode != "disabled": + if mode != "disabled" and not skip_store: # 安全创建目录,并写入 .gitignore(如果目录为空) helper.mkdir_and_append_gitignore(run_settings.log_dir) # 创建运行子目录,run_dir 必须是新建的,防止误覆盖已有实验数据 @@ -559,7 +567,7 @@ def _init(run_settings: Settings, callbacks: Optional[CallbacksType]) -> Tuple[R parallel=run_settings.run.parallel, ) else: - # 禁用模式下的run_dir为一个示例目录,仅用于满足上下文类型限制 + # disabled / skip_store 下 run_dir 仅作逻辑路径,不创建目录 if run_settings.run.dir: run_dir = run_settings.log_dir / run_settings.run.dir else: @@ -646,7 +654,7 @@ def _init(run_settings: Settings, callbacks: Optional[CallbacksType]) -> Tuple[R set_nested_value(sync_args, key, value) run_settings.merge_settings(sync_args) # 4. 创建运行目录 - if mode != "disabled": + if mode != "disabled" and not skip_store: fs.safe_mkdirs(ctx.media_dir, ctx.files_dir, ctx.debug_dir) return ctx, path diff --git a/swanlab/sdk/internal/bus/events.py b/swanlab/sdk/internal/bus/events.py index f4bc68e82..be2efc4e0 100644 --- a/swanlab/sdk/internal/bus/events.py +++ b/swanlab/sdk/internal/bus/events.py @@ -59,6 +59,9 @@ class ConfigEvent: path: Path timestamp: Timestamp + # 配置内容快照,与 config.yaml 落盘内容与后端一致: {key: {value, desc, sort}} + # 未启用时仅作信息载体;skip_store 下 config 不落盘,消费端据此序列化出 SaveRecord.payload。 + content: Dict[str, Any] @dataclass diff --git a/swanlab/sdk/internal/context/transformer.py b/swanlab/sdk/internal/context/transformer.py index 20cd03bbe..6be585c7a 100644 --- a/swanlab/sdk/internal/context/transformer.py +++ b/swanlab/sdk/internal/context/transformer.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, List, Union +from typing import Any, List, Optional, Union from google.protobuf.message import Message from google.protobuf.timestamp_pb2 import Timestamp @@ -112,11 +112,11 @@ def build_data_record(cls, *, key: str, step: int, timestamp: Timestamp, data: L ) @abstractmethod - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: """ - 将媒体数据转换为Protobuf格式,并将结果写入指定目录下 + 将媒体数据转换为Protobuf格式 :param step: 步数 - :param path: 存储目录 + :param path: 存储目录;None 即 skip_store,不落盘,内容写入 MediaItem.payload :return: Protobuf消息 """ ... diff --git a/swanlab/sdk/internal/core_python/api/upload.py b/swanlab/sdk/internal/core_python/api/upload.py index a46c066cd..1e4b87768 100644 --- a/swanlab/sdk/internal/core_python/api/upload.py +++ b/swanlab/sdk/internal/core_python/api/upload.py @@ -14,7 +14,7 @@ from requests.sessions import Session from swanlab.sdk.internal.core_python import client -from swanlab.sdk.internal.core_python.utils import ProgressFileWrapper, get_buffer_size +from swanlab.sdk.internal.core_python.utils import MemoryViewReader, ProgressFileWrapper, get_buffer_size from swanlab.sdk.internal.pkg import safe from swanlab.sdk.internal.pkg.executor import SafeThreadPoolExecutor from swanlab.sdk.typings.core_python.api.upload import ( @@ -150,7 +150,7 @@ def upload_resource( experiment_id: str, *, paths: List[str], - buffers: List[Union[IO[bytes], str, Path]], + buffers: List[Union[IO[bytes], MemoryViewReader, str, Path]], content_types: Optional[List[str]] = None, tracker: Optional[UploadTracker] = None, ): @@ -166,7 +166,9 @@ def upload_resource( ) urls = resp.data["urls"] - def upload_one(url: str, buffer: Union[IO[bytes], str, Path], file_key: str, size: int, content_type: str): + def upload_one( + url: str, buffer: Union[IO[bytes], MemoryViewReader, str, Path], file_key: str, size: int, content_type: str + ): # 上传单个文件,支持文件路径和内存 buffer 两种形式 if isinstance(buffer, (str, Path)): with open(buffer, "rb") as f: @@ -245,7 +247,7 @@ def _reset_tracked_file(tracker: Optional[UploadTracker], file_key: Optional[str def _put_with_progress( session: Session, url: str, - file_obj: IO[bytes], + file_obj: Union[IO[bytes], MemoryViewReader], file_key: Optional[str], size: Optional[int], tracker: Optional[UploadTracker], @@ -254,7 +256,7 @@ def _put_with_progress( ) -> None: """PUT 上传单个文件,有 tracker 时用 ProgressFileWrapper 汇报读字节进度。""" if tracker is not None and file_key is not None and size: - data: Union[IO[bytes], ProgressFileWrapper] = ProgressFileWrapper( + data: Union[IO[bytes], MemoryViewReader, ProgressFileWrapper] = ProgressFileWrapper( file_obj, on_read=lambda current: tracker.update_file_progress(file_key, file_key, current), total_size=size, diff --git a/swanlab/sdk/internal/core_python/context/__init__.py b/swanlab/sdk/internal/core_python/context/__init__.py index 78923e074..7c4c74157 100644 --- a/swanlab/sdk/internal/core_python/context/__init__.py +++ b/swanlab/sdk/internal/core_python/context/__init__.py @@ -27,6 +27,8 @@ class CoreConfig: save_split: int save_part: int save_batch: int + # 跳过本地 datastore 落盘(仅 online 模式合法,由根 Settings 校验器保证) + skip_store: bool = False class CoreContext: @@ -52,6 +54,7 @@ def from_proto(cls, proto: CoreSettings, mode: Literal["core", "sync"] = "core") save_split=proto.save_split, save_part=proto.save_part, save_batch=proto.save_batch, + skip_store=proto.skip_store, ) return cls(config=config, mode=mode) diff --git a/swanlab/sdk/internal/core_python/core.py b/swanlab/sdk/internal/core_python/core.py index bf083939a..850cc0dbd 100644 --- a/swanlab/sdk/internal/core_python/core.py +++ b/swanlab/sdk/internal/core_python/core.py @@ -105,10 +105,10 @@ def deliver_run_start(self, start_request: DeliverRunStartRequest) -> DeliverRun return resp def _start_store(self, resp: DeliverRunStartResponse): - self._store = DataStoreWriter() + # skip_store 仅 online 模式为 True(根 Settings 校验器保证),此时不创建本地 datastore 文件 + self._store = DataStoreWriter(skip=self._ctx.config.skip_store) self._store.open(str(self._ctx.run_file)) - record = builder.build_start_record(resp.run) - self._store.write(record.SerializeToString()) + self._store_records([builder.build_start_record(resp.run)]) def _start_without_online(self, start_request: DeliverRunStartRequest, message: str) -> DeliverRunStartResponse: self._ctx = CoreContext.from_proto(start_request.core_settings) @@ -189,8 +189,15 @@ def _report_run_start(self, record: StartRecord) -> DeliverRunStartResponse: # ---------------------------------- 数据上报 ---------------------------------- def _store_records(self, records: List[Record]) -> None: - """将一组 Record 写入本地存储""" + """将一组 Record 写入本地存储;start、普通 record 与 finish 的统一入口。 + + skip_store 下不序列化、不落盘,仅登记未持久化计数(writer.close() 统计用)。 + """ assert self._store is not None, "store must be initialized before upsert" + if self._ctx.config.skip_store: + # 跳过 SerializeToString 与落盘,仅登记未持久化计数(writer.close() 统计用) + self._store.skip_records(len(records)) + return for record in records: self._store.write(record.SerializeToString()) @@ -346,8 +353,9 @@ def _upsert_logs_when_online(self, logs: List[LogRecord]) -> None: records = [builder.build_log_record(self._counter, self._epoch, c) for c in logs] self._store_records(records) if records: + action = "Skipped storing" if self._ctx.config.skip_store else "Stored" console.debug( - f"Stored log records locally: count={len(records)}, nums={records[0].num}..{records[-1].num}", + f"{action} log records locally: count={len(records)}, nums={records[0].num}..{records[-1].num}", write_to_tty=False, ) self._transport_put(records) @@ -392,6 +400,13 @@ def upsert_saves(self, saves: List[SaveRecord]) -> None: self._upsert_saves_when_online(custom_saves) def _handle_custom_save(self, saves: List[SaveRecord]) -> List[Record]: + # skip_store:不创建本地镜像软链接(不触碰 files_dir、不填 target_path), + # live 监听直接对准源文件 + if self._ctx.config.skip_store: + records = [builder.build_save_record(self._counter, s) for s in saves] + self._store_records(records) + self._watcher.register_source_watches(saves) + return records linked = create_save_links(saves, self._ctx.files_dir) if linked > 0: console.info( @@ -467,7 +482,7 @@ def _store_finish(self, finish_record: FinishRecord) -> Optional[Record]: # 不将 log_record 写入 store 中,一方面具体的报错信息存储在 finish_record 中 # 另一方面因为这个 record 也是为了适应后端“报错信息写在CH”的设计 # 2. 关闭存储、文件监视器等本地资源,停止接受新的记录 - self._store.write(record.SerializeToString()) + self._store_records([record]) self._store.close() self._store = None self._watcher.stop() @@ -548,5 +563,13 @@ def _confirm_finish_when_enabled(self) -> ConfirmRunFinishResponse: ) self._pending_online_finish_record = None return ConfirmRunFinishResponse(success=True, message="OK") - # 虽然本地已经完成了全部流程,但由于网络等原因导致无法通知后端,因此返回失败状态,但是影响不大 - return ConfirmRunFinishResponse(success=False, message="Failed to finish run, but it has been saved locally.") + # 虽然本地已经完成了全部流程,但由于网络等原因导致无法通知后端,因此返回失败状态,但是影响不大。 + # skip_store 下没有本地副本,提示语不能暗示数据仍可从本地恢复 + if self._ctx.config.skip_store: + message = ( + "Failed to finish run, and no local copy was kept (skip_store); " + "the run state on the cloud is unconfirmed." + ) + else: + message = "Failed to finish run, but it has been saved locally." + return ConfirmRunFinishResponse(success=False, message=message) diff --git a/swanlab/sdk/internal/core_python/store/__init__.py b/swanlab/sdk/internal/core_python/store/__init__.py index 182faf55e..47177fe5f 100644 --- a/swanlab/sdk/internal/core_python/store/__init__.py +++ b/swanlab/sdk/internal/core_python/store/__init__.py @@ -46,15 +46,27 @@ class DataStoreWriter: - """追加写入器,持有一个长期打开的二进制文件句柄。""" + """追加写入器,持有一个长期打开的二进制文件句柄。 - def __init__(self): + ``skip=True`` 时跳过落盘(对应 ``core.skip_store``,仅 online 模式合法): + ``open()`` 不创建文件、``write()`` 拒绝写入、``close()`` 输出 debug 统计, + 跳过的 record 由调用方(Core 的 ``_store_records``)经 ``skip_records()`` 登记计数。 + 此时记录只经 Transport 上传云端,本地不产生 ``run-*.swanlab``,因此进程崩溃后 + 未确认的 record 无法恢复、也无法通过 ``swanlab sync`` 补传。默认 ``skip=False`` 行为不变。 + """ + + def __init__(self, skip: bool = False): self._fp: Optional[IO[Any]] = None self._index: int = 0 self._flush_offset: int = 0 + self._skip = skip + # skip 设置时经 skip_records() 登记的未持久化 record 数,仅用于 close() 的 debug 统计 + self._skipped_records: int = 0 def open(self, filename: Union[Path, str]) -> None: - """创建并初始化文件,文件已存在时抛出 FileExistsError。""" + """创建并初始化文件,文件已存在时抛出 FileExistsError;skip 设置时不创建文件。""" + if self._skip: + return self._fp = open(filename, "xb") header = struct.pack("<4sHB", LEVELDBLOG_HEADER_IDENT, LEVELDBLOG_HEADER_MAGIC, LEVELDBLOG_HEADER_VERSION) assert len(header) == LEVELDBLOG_HEADER_LEN @@ -62,7 +74,8 @@ def open(self, filename: Union[Path, str]) -> None: self._index += len(header) def write(self, data: bytes) -> None: - """写入任意字节,遵循 LevelDB log 分块规范。""" + """写入任意字节,遵循 LevelDB log 分块规范;skip writer 不接受写入。""" + assert not self._skip, "cannot write records to a skip writer" assert self._fp is not None, "writer is not open" offset = self._index % LEVELDBLOG_BLOCK_LEN space_left = LEVELDBLOG_BLOCK_LEN - offset @@ -99,11 +112,21 @@ def write(self, data: bytes) -> None: # pass # self._flush_offset = self._index + def skip_records(self, count: int) -> None: + """skip 设置时登记 count 条未持久化的 record。 + + skip writer 不接受 write(),跳过的 record 均经此方法维持 close() 统计的完整性。 + """ + self._skipped_records += count + def ensure_flushed(self) -> None: assert self._fp is not None, "writer is not open" self._fp.flush() def close(self) -> None: + if self._skip: + console.debug(f"local store skipped, {self._skipped_records} records not persisted") + return assert self._fp is not None, "writer is not open" self._fp.flush() self._fp.close() diff --git a/swanlab/sdk/internal/core_python/transport/sender.py b/swanlab/sdk/internal/core_python/transport/sender.py index 797c3da6a..171073c48 100644 --- a/swanlab/sdk/internal/core_python/transport/sender.py +++ b/swanlab/sdk/internal/core_python/transport/sender.py @@ -12,7 +12,7 @@ import threading from collections.abc import Callable, Sequence from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import IO, TYPE_CHECKING, Literal, Optional, Union, cast +from typing import IO, TYPE_CHECKING, Any, Literal, Optional, Union, cast import yaml @@ -40,7 +40,7 @@ ) from swanlab.sdk.internal.core_python.context import CoreContext from swanlab.sdk.internal.core_python.pkg.mime import guess_type -from swanlab.sdk.internal.core_python.utils import ProgressFileWrapper, get_buffer_size +from swanlab.sdk.internal.core_python.utils import MemoryViewReader, ProgressFileWrapper, get_buffer_size from swanlab.sdk.internal.pkg import adapter, client, console, safe from swanlab.sdk.internal.pkg.client.session import SessionWithRetry from swanlab.sdk.internal.pkg.executor import SafeThreadPoolExecutor @@ -177,7 +177,7 @@ def upload_scalar(self, records: Sequence[Record]) -> None: def upload_media(self, records: Sequence[Record]) -> None: metrics: UploadMediaBatch = [] paths: list[str] = [] - buffers: list[Union[IO[bytes], str, Path]] = [] + buffers: list[Union[IO[bytes], MemoryViewReader, str, Path]] = [] content_types: list[str] = [] for record in records: if not record.HasField("media"): @@ -198,14 +198,32 @@ def upload_media(self, records: Sequence[Record]) -> None: # 约定保存到对象存储中的文件路径类似于本地文件路径 medium = adapter.medium[media_record.type] remote_path = PurePosixPath("media", medium, media.filename) - local_path = self._ctx.media_dir / medium / media.filename + remote_path_str = remote_path.as_posix() + + if media.HasField("payload"): + # skip_store:内容在 payload(允许为空文件),直接内存上传,不触碰本地 media 路径 + size = len(media.payload) + mime_type = guess_type(media.filename) + self._track_file(f"{remote_path_str}:{size}", remote_path_str, size) + record_paths.append(remote_path_str) + paths.append(remote_path_str) + buffers.append(MemoryViewReader(media.payload)) + content_types.append(mime_type) + if media.caption: + metric_chunk["more"].append({"caption": media.caption}) + continue + if self._ctx.config.skip_store: + # skip_store 下缺 payload:告警丢弃,不回退读本地路径 + console.warning(f"Media payload missing with skip_store enabled, skipping: {remote_path_str}") + continue + + local_path = self._ctx.media_dir / medium / media.filename if not local_path.is_file(): continue with safe.block(message="Failed to process media file, skipping"): size = get_buffer_size(local_path) - remote_path_str = remote_path.as_posix() tracker_key = f"{remote_path_str}:{size}" self._track_file(tracker_key, local_path.as_posix(), size) # 先计算 mime_type, 确保后续 record_paths/paths/buffers/content_types 原子化追加, @@ -260,84 +278,46 @@ def upload_log(self, records: Sequence[Record]) -> None: # ── 文件保存上传 ── - def _resolve_save_source(self, save: SaveRecord) -> Optional[Path]: - """解析 save 记录对应的可读本地文件路径。 - - ``source_path`` 为训练机绝对路径;online/local/offline 本机运行时可读。 - sync 在另一台机器、以不同挂载根读取同一 run 目录时该绝对路径不可读, - 此时回退到当前 run 目录的 ``files`` 子目录按文件名重新定位: + def upload_internal_save(self, record: Record) -> None: + """上传单条内部 save:读数据 → 解码 → 上传(payload 与磁盘双通道)。 - - 用户保存(CUSTOM):按记录相对名 ``save.name`` 定位(镜像位置,与 ``create_save_links`` 约定一致); - - 内部保存(metadata/requirements/conda/config):由 probe 直接写入 files 目录(真实文件), - 按 ``source_path`` 的 basename 定位。注意 config 的 ``save.name`` 为 ``"config"`` 而非 ``config.yaml``, - 故此处一律用 basename 而非 name。 - - :param save: 文件保存记录 - :return: 可读的本地文件路径;若原始路径与回退路径均不可读则返回 None + 前两步返回 None 即已告警跳过;上传异常不捕获,5xx 交 Transport 重试、4xx 由外层跳过。 """ - primary = Path(save.source_path) - if primary.is_file(): - return primary - # source_path/name 可能由异构系统写入(例如训练在 Windows,sync 在 POSIX), - # 其分隔符为反斜杠时,POSIX 的 Path 无法正确切分。用 PureWindowsPath 仅做分隔符解析, - # 再交给本地 Path 复原,确保 basename 取正确文件名、子目录层级被正确还原。 - if save.type == SaveType.SAVE_TYPE_CUSTOM: - fallback = self._ctx.files_dir / Path(*PureWindowsPath(save.name).parts) - else: - fallback = self._ctx.files_dir / PureWindowsPath(save.source_path).name - if fallback.is_file(): - console.debug(f"Save source path not readable, recovered from run directory: {fallback}") - return fallback - return None + save = record.save + data = self.read_internal_save(save) + if data is None: + return + content = self.decode_internal_save(save.type, data) + if content is None: + return + if save.type == SaveType.SAVE_TYPE_METADATA: + upload_metadata(self._username, self._project, self._experiment_id, content=content) + elif save.type == SaveType.SAVE_TYPE_REQUIREMENTS: + upload_requirements(self._username, self._project, self._experiment_id, content=content) + elif save.type == SaveType.SAVE_TYPE_CONDA: + upload_conda(self._username, self._project, self._experiment_id, content=content) + elif save.type == SaveType.SAVE_TYPE_CONFIG: + upload_config(self._username, self._project, self._experiment_id, content=content) def upload_save(self, records: Sequence[Record]) -> None: + """内部保存仅上传内容,用户保存(CUSTOM)走文件上传(小文件 presigned URL,大文件分片)。 + + skip_store 下内部保存在 payload 中,CUSTOM 只读 source_path。 """ - 内部保存(如config、metadata等)和用户保存(文件保存)共用 SaveRecord 结构 - 目前它们在产品设计上暂未统一,换句话说上传config、metadata文件的时候并不会保存对应文件 - 因此需要区分两者,内部保存仅上传,用户保存走save逻辑:小文件走 presigned URL,大文件走分片上传。 - """ - # 1. 区分内部保存和用户保存,过滤出需要走文件上传逻辑的记录,并处理内部保存的上传 + # 1. 分流:内部保存直接上传,CUSTOM 收集后走文件上传 save_records = [] for record in records: if not record.HasField("save"): continue save = record.save - # 根据约定的 type 字段区分内部保存和用户保存,内部保存直接上传内容,用户保存走后续文件上传逻辑 if save.type == SaveType.SAVE_TYPE_CUSTOM: + # CUSTOM 的 payload 必须缺席,present 视为协议违约丢弃(防大文件误入内存通道) + if save.HasField("payload"): + console.warning(f"CUSTOM save must not carry payload, skipping: {save.name or save.source_path}") + continue save_records.append(record) - continue - # 内部保存(metadata/requirements/conda/config):source_path 为训练机绝对路径, - # 跨挂载根 sync 时不可读,回退到 run 目录 files 子目录按 basename 重新定位 - source_ref_path = self._resolve_save_source(save) - if source_ref_path is None: - console.warning(f"Save file not found, skipping: {save.source_path}") - continue - if save.type == SaveType.SAVE_TYPE_METADATA: - with safe.block(message=f"Failed to upload metadata, skipping; file kept at {source_ref_path}"): - with open(source_ref_path, "r", encoding="utf-8") as f: - content = json.load(f) - if isinstance(content, dict): - upload_metadata(self._username, self._project, self._experiment_id, content=content) - elif save.type == SaveType.SAVE_TYPE_REQUIREMENTS: - with safe.block(message=f"Failed to upload requirements, skipping; file kept at {source_ref_path}"): - with open(source_ref_path, "r", encoding="utf-8") as f: - content = f.read() - if len(content) > 0: - upload_requirements(self._username, self._project, self._experiment_id, content=content) - elif save.type == SaveType.SAVE_TYPE_CONDA: - with safe.block(message=f"Failed to upload conda, skipping; file kept at {source_ref_path}"): - with open(source_ref_path, "r", encoding="utf-8") as f: - content = f.read() - if len(content) > 0: - upload_conda(self._username, self._project, self._experiment_id, content=content) - elif save.type == SaveType.SAVE_TYPE_CONFIG: - with safe.block(message=f"Failed to upload config, skipping; file kept at {source_ref_path}"): - with open(source_ref_path, "r", encoding="utf-8") as f: - content = yaml.safe_load(f) - if isinstance(content, dict): - upload_config(self._username, self._project, self._experiment_id, content=content) else: - console.warning(f"Unknown save type {save.type} for record num {record.num}, skipping") + self.upload_internal_save(record) if not save_records: console.debug("No user save records to upload after filtering; all saves are internal files.") return @@ -350,7 +330,7 @@ def upload_save(self, records: Sequence[Record]) -> None: if not record.HasField("save"): continue save = record.save - source_ref_path = self._resolve_save_source(save) + source_ref_path = self.resolve_save_source(save) if source_ref_path is None: console.warning(f"Save file not found, skipping: {save.source_path}") continue @@ -379,6 +359,67 @@ def upload_save(self, records: Sequence[Record]) -> None: for index in range(0, len(pending), config.save_batch): self._upload_save_batch(pending[index : index + config.save_batch]) + # ── upload save 辅助方法 ── + + def resolve_save_source(self, save: SaveRecord) -> Optional[Path]: + """解析 save 记录的可读本地文件路径,原始路径与回退路径均不可读时返回 None。 + + ``source_path`` 为绝对路径,跨挂载根 sync 时不可读,回退到 run 目录 ``files`` 子目录: + CUSTOM 按相对名 ``save.name`` 定位,内部保存按 ``source_path`` 的 basename 定位 + (config 的 ``save.name`` 为 ``"config"`` 而非 ``config.yaml``,故一律用 basename)。 + """ + primary = Path(save.source_path) + if primary.is_file(): + return primary + # 反斜杠路径(在 Windows、sync 在 POSIX)需经 PureWindowsPath 解析分隔符, + # 本地 Path 才能取到正确的 basename 和子目录层级 + if save.type == SaveType.SAVE_TYPE_CUSTOM: + fallback = self._ctx.files_dir / Path(*PureWindowsPath(save.name).parts) + else: + fallback = self._ctx.files_dir / PureWindowsPath(save.source_path).name + if fallback.is_file(): + console.debug(f"Save source path not readable, recovered from run directory: {fallback}") + return fallback + return None + + def read_internal_save(self, save: SaveRecord) -> Optional[bytes]: + """内部 save 数据源:payload 优先,默认模式回退磁盘;无法访问时告警并返回 None。""" + if save.HasField("payload"): + return save.payload + if self._ctx.config.skip_store: + console.warning(f"Internal save payload missing with skip_store enabled, skipping: {save.type}") + return None + source = self.resolve_save_source(save) + if source is None: + console.warning(f"Save file not found, skipping: {save.source_path}") + return None + try: + return source.read_bytes() + except OSError as e: + console.warning(f"Failed to read save file, skipping; file kept at {source}: {e}") + return None + + def decode_internal_save(self, save_type: SaveType, data: bytes) -> Optional[Any]: + """按 SaveType 解码内部 save 字节:metadata/config 为 dict,requirements/conda 为非空 str。 + + 脏数据告警后返回 None,空内容静默返回 None,均不进入 Transport 无上限重试。 + """ + try: + if save_type == SaveType.SAVE_TYPE_METADATA: + content = json.loads(data.decode("utf-8")) + return content if isinstance(content, dict) else None + if save_type in (SaveType.SAVE_TYPE_REQUIREMENTS, SaveType.SAVE_TYPE_CONDA): + text = data.decode("utf-8") + return text or None + if save_type == SaveType.SAVE_TYPE_CONFIG: + content = yaml.safe_load(data.decode("utf-8")) + return content if isinstance(content, dict) else None + except (UnicodeDecodeError, json.JSONDecodeError, yaml.YAMLError) as e: + console.warning(f"Failed to parse internal save payload, skipping: type={save_type}, error={e}") + return None + console.warning(f"Unknown internal save type {save_type}, skipping") + return None + def _upload_save_batch(self, pending: Sequence[tuple[str, int, str]]) -> None: """上传一个 save_batch 内的文件列表。""" config = self._ctx.config diff --git a/swanlab/sdk/internal/core_python/utils.py b/swanlab/sdk/internal/core_python/utils.py index ea26e92d7..29512249e 100644 --- a/swanlab/sdk/internal/core_python/utils.py +++ b/swanlab/sdk/internal/core_python/utils.py @@ -5,6 +5,7 @@ @description: Core 服务共享工具函数 """ +import io import os from dataclasses import dataclass from pathlib import Path @@ -117,6 +118,50 @@ def get_buffer_size(buffer: Any) -> int: raise TypeError("Object has no len") +class MemoryViewReader(io.RawIOBase): + """基于 memoryview 的只读、可 seek reader,避免 payload → BytesIO 的整段拷贝。 + + 接口与 BytesIO 兼容,可直接作为 requests 的上传 body。 + """ + + def __init__(self, data: bytes) -> None: + self._view = memoryview(data) + self._position = 0 + + def read(self, size: int = -1) -> bytes: + if size is None or size < 0: + chunk = self._view[self._position :] + self._position = len(self._view) + else: + chunk = self._view[self._position : self._position + size] + self._position += len(chunk) + return chunk.tobytes() + + def seek(self, offset: int, whence: int = 0) -> int: + if whence == 0: + position = offset + elif whence == 1: + position = self._position + offset + elif whence == 2: + position = len(self._view) + offset + else: + raise ValueError(f"Invalid whence: {whence}") + self._position = min(max(int(position), 0), len(self._view)) + return self._position + + def tell(self) -> int: + return self._position + + def readable(self) -> bool: + return True + + def seekable(self) -> bool: + return True + + def __len__(self) -> int: + return len(self._view) + + class ProgressFileWrapper: """包装文件对象,在每次 read() 后回调汇报已读字节数。 diff --git a/swanlab/sdk/internal/core_python/watcher/__init__.py b/swanlab/sdk/internal/core_python/watcher/__init__.py index 0fff9d00a..efb5d0dd9 100644 --- a/swanlab/sdk/internal/core_python/watcher/__init__.py +++ b/swanlab/sdk/internal/core_python/watcher/__init__.py @@ -1,14 +1,18 @@ """ 基于 watchdog 的文件监听器,采用 trailing debounce 策略。 -监听 swanlog/{run_id}/files/ 目录下的文件变化, +两种注册模式: + 1. 镜像模式(默认):监听 swanlog/{run_id}/files/ 下的软链接镜像; + 2. direct-source(skip_store 下):没有本地镜像目录,直接监听用户源文件所在目录, + 事件路径与注册的源文件绝对路径精确匹配,同目录其他文件的变化被忽略。 + 文件稳定(停止写入 debounce_delay 秒)后触发 on_change 回调。 """ import os import threading from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set, Tuple from watchdog.observers import Observer @@ -23,20 +27,21 @@ class FileWatcher: 参数: on_change: 文件变化后的回调,接收 (abs_path, SaveRecord) - debounce_delay: 文件停止变化后等待多少秒再触发回调,默认 1.0 + debounce_delay: 文件停止变化后等待多少秒再触发回调,默认 1.5 """ - def __init__(self, on_change: OnChangeCallback, debounce_delay: float = 1.0): + def __init__(self, on_change: OnChangeCallback, debounce_delay: float = 1.5): self._on_change = on_change self._debounce_delay = debounce_delay self._observer = Observer() self._timers: Dict[str, threading.Timer] = {} - self._registered: Dict[str, FileEntry] = {} # abs_path → entry + self._registered: Dict[str, List[FileEntry]] = {} # 事件路径 → entries(同源多 name 时为多条) + self._scheduled_dirs: Set[str] = set() # 已调度监听的目录绝对路径 self._lock = threading.Lock() self._started = False def watch(self, dir_path: str, file_paths: List[str], policies: Optional[List[int]] = None) -> None: - """注册并开始监听指定目录下的文件。 + """镜像模式:注册并开始监听指定目录下的文件。 :param dir_path: 监听目录的绝对路径 :param file_paths: 相对于 dir_path 的文件路径列表 @@ -48,8 +53,6 @@ def watch(self, dir_path: str, file_paths: List[str], policies: Optional[List[in with self._lock: for idx, rel in enumerate(file_paths): abs_path = str(Path(dir_abs) / rel) - if abs_path in self._registered: - continue source_path = self._resolve_source(abs_path) entry = FileEntry( name=rel, @@ -58,11 +61,54 @@ def watch(self, dir_path: str, file_paths: List[str], policies: Optional[List[in policy=policies[idx] if policies and idx < len(policies) else None, signature=compute_signature(abs_path), ) - self._registered[abs_path] = entry + self._register(abs_path, entry) + + self._ensure_scheduled(dir_abs) + + def watch_sources(self, saves: List[SaveRecord]) -> None: + """direct-source 模式:不依赖本地镜像目录,直接监听 source_path 所在目录。 + + - 按 source_path.parent 分组,一个目录只 schedule 一次; + - _registered 以源文件绝对路径为 key,事件精确匹配(同目录其他文件被忽略); + - 同一源文件保存为多个 name 时一对多注册,变化时为每个 name 各触发一次回调。 + """ + # 1. 按源文件父目录分组 + groups: Dict[str, List[Tuple[str, SaveRecord]]] = {} + for save in saves: + if not save.source_path: + continue + source_abs = str(Path(save.source_path).resolve()) + groups.setdefault(str(Path(source_abs).parent), []).append((source_abs, save)) + # 2. 逐目录注册并调度监听 + for dir_abs, group in groups.items(): + with self._lock: + for source_abs, save in group: + self._register( + source_abs, + FileEntry( + name=save.name, + source_path=source_abs, + target_path="", # direct-source:无本地镜像路径 + policy=save.policy, + signature=compute_signature(source_abs), + ), + ) + self._ensure_scheduled(dir_abs) + + def _register(self, event_path: str, entry: FileEntry) -> None: + """登记一条监听(调用方持锁)。同一事件路径可挂多个 name,按 (name, source_path) 幂等。""" + entries = self._registered.setdefault(event_path, []) + if any(e.name == entry.name and e.source_path == entry.source_path for e in entries): + return + entries.append(entry) - # 启动 watchdog(只需一次) + def _ensure_scheduled(self, dir_abs: str) -> None: + """确保目录已被 watchdog 监听;首次调度时启动 observer 线程。""" + if dir_abs in self._scheduled_dirs: + return + self._scheduled_dirs.add(dir_abs) + self._observer.schedule(_Handler(self), dir_abs, recursive=False) if not self._started: - self._observer.schedule(_Handler(self), dir_abs, recursive=False) self._observer.start() self._started = True @@ -88,49 +134,57 @@ def _schedule_debounce(self, path: str) -> None: timer.start() def _process_change(self, path: str) -> None: - """定时器到期后执行:计算签名 → 对比 → 触发回调。""" + """定时器到期后执行:计算签名 → 对比 → 触发回调。同一路径的所有条目各回调一次。""" with self._lock: self._timers.pop(path, None) - entry = self._registered.get(path) - if entry is None: + entries = self._registered.get(path) + if not entries: return + entries = list(entries) - # 文件被删除则移除注册 + # 文件暂时不存在(删除后重建、替换窗口等)时保留注册,由后续 on_created/on_moved + # 复活监听;若此时移除注册,重建后的 on_created 会因路径不在注册表被忽略,监听永久丢失 new_sig = compute_signature(path) if new_sig is None: - with self._lock: - self._registered.pop(path, None) - return - - # 签名未变则忽略 - if new_sig == entry.signature: return - # 签名变化,更新并触发回调 - entry.signature = new_sig - record = SaveRecord( - name=entry.name, - source_path=entry.source_path, - target_path=entry.target_path, - ) - if entry.policy is not None: - record.policy = entry.policy # type: ignore[assignment] - with safe.block(message=f"FileWatcher on_change callback error for {path}"): - self._on_change(record) + for entry in entries: + # 签名未变则忽略 + if new_sig == entry.signature: + continue + # 签名变化,更新并触发回调 + entry.signature = new_sig + record = SaveRecord( + name=entry.name, + source_path=entry.source_path, + target_path=entry.target_path, + ) + if entry.policy is not None: + record.policy = entry.policy # type: ignore[assignment] + with safe.block(message=f"FileWatcher on_change callback error for {path}"): + self._on_change(record) def register_live_watches(self, save_records: List[SaveRecord], files_dir: Path) -> None: - """对 policy=SAVE_POLICY_LIVE 的记录注册文件监听。""" + """镜像模式:对 policy=SAVE_POLICY_LIVE 的记录注册文件监听。""" live_files = [s for s in save_records if s.policy == SavePolicy.SAVE_POLICY_LIVE] if not live_files: return self.watch(str(files_dir), [s.name for s in live_files], [s.policy for s in live_files]) + def register_source_watches(self, save_records: List[SaveRecord]) -> None: + """direct-source 模式(skip_store 下):对 policy=SAVE_POLICY_LIVE 的记录直接监听源文件。""" + live_files = [s for s in save_records if s.policy == SavePolicy.SAVE_POLICY_LIVE] + if not live_files: + return + self.watch_sources(live_files) + def stop(self) -> None: """停止监听,释放资源。""" with self._lock: for timer in self._timers.values(): timer.cancel() self._timers.clear() + self._scheduled_dirs.clear() if self._started: self._observer.stop() diff --git a/swanlab/sdk/internal/core_python/watcher/helper.py b/swanlab/sdk/internal/core_python/watcher/helper.py index acb219283..09aa06509 100644 --- a/swanlab/sdk/internal/core_python/watcher/helper.py +++ b/swanlab/sdk/internal/core_python/watcher/helper.py @@ -56,6 +56,11 @@ def on_created(self, event): if not event.is_directory: self._watcher._schedule_debounce(str(event.src_path)) + def on_moved(self, event): + # 原子替换(写临时文件后 os.replace 到目标路径)在 Linux/Windows 上只上报 moved 事件 + if not event.is_directory: + self._watcher._schedule_debounce(str(event.dest_path)) + def create_save_links(saves: List[SaveRecord], files_dir: Path) -> int: """为 SaveRecord 创建软链接并填充 target_path,返回新建链接数量。""" diff --git a/swanlab/sdk/internal/probe_python/__init__.py b/swanlab/sdk/internal/probe_python/__init__.py index 550075f13..cbb005ba5 100644 --- a/swanlab/sdk/internal/probe_python/__init__.py +++ b/swanlab/sdk/internal/probe_python/__init__.py @@ -88,30 +88,54 @@ def _start_when_enabled(self, start_request: DeliverProbeStartRequest): ) if self._core is not None: # 4. 向core发送记录 + # skip_store:内容直接进入 SaveRecord.payload、source_path 留空,不落盘 + skip_store = ctx.config.skip_store payload: List[SaveRecord] = [] if sys_info.metadata: - fs.safe_write(ctx.metadata_file, sys_info.metadata.model_dump_json(by_alias=True)) - metadata_record = SaveRecord( - name="metadata", - source_path=ctx.metadata_file.absolute().as_posix(), - type=SaveType.SAVE_TYPE_METADATA, - ) + content = sys_info.metadata.model_dump_json(by_alias=True) + if skip_store: + metadata_record = SaveRecord( + name="metadata", + type=SaveType.SAVE_TYPE_METADATA, + payload=content.encode("utf-8"), + ) + else: + fs.safe_write(ctx.metadata_file, content) + metadata_record = SaveRecord( + name="metadata", + source_path=ctx.metadata_file.absolute().as_posix(), + type=SaveType.SAVE_TYPE_METADATA, + ) payload.append(metadata_record) if sys_info.requirements: - fs.safe_write(ctx.requirements_file, sys_info.requirements) - requirements_record = SaveRecord( - name="requirements", - source_path=ctx.requirements_file.absolute().as_posix(), - type=SaveType.SAVE_TYPE_REQUIREMENTS, - ) + if skip_store: + requirements_record = SaveRecord( + name="requirements", + type=SaveType.SAVE_TYPE_REQUIREMENTS, + payload=sys_info.requirements.encode("utf-8"), + ) + else: + fs.safe_write(ctx.requirements_file, sys_info.requirements) + requirements_record = SaveRecord( + name="requirements", + source_path=ctx.requirements_file.absolute().as_posix(), + type=SaveType.SAVE_TYPE_REQUIREMENTS, + ) payload.append(requirements_record) if sys_info.conda: - fs.safe_write(ctx.conda_file, sys_info.conda) - conda_record = SaveRecord( - name="conda", - source_path=ctx.conda_file.absolute().as_posix(), - type=SaveType.SAVE_TYPE_CONDA, - ) + if skip_store: + conda_record = SaveRecord( + name="conda", + type=SaveType.SAVE_TYPE_CONDA, + payload=sys_info.conda.encode("utf-8"), + ) + else: + fs.safe_write(ctx.conda_file, sys_info.conda) + conda_record = SaveRecord( + name="conda", + source_path=ctx.conda_file.absolute().as_posix(), + type=SaveType.SAVE_TYPE_CONDA, + ) payload.append(conda_record) if payload: self._core.upsert_saves(payload) diff --git a/swanlab/sdk/internal/probe_python/context/__init__.py b/swanlab/sdk/internal/probe_python/context/__init__.py index e1cc1584d..fdb0f7ff1 100644 --- a/swanlab/sdk/internal/probe_python/context/__init__.py +++ b/swanlab/sdk/internal/probe_python/context/__init__.py @@ -30,6 +30,7 @@ class ProbeConfig: monitor: bool monitor_interval: int monitor_disk_dir: Path + skip_store: bool = False class ProbeContext: @@ -59,6 +60,7 @@ def from_proto(cls, proto: ProbeSettings) -> "ProbeContext": monitor=proto.monitor, monitor_interval=proto.monitor_interval, monitor_disk_dir=Path(proto.monitor_disk_dir), + skip_store=proto.skip_store, ) return cls(config=config) diff --git a/swanlab/sdk/internal/probe_python/environment/swanlab.py b/swanlab/sdk/internal/probe_python/environment/swanlab.py index 5c43c7fab..ac551a90c 100644 --- a/swanlab/sdk/internal/probe_python/environment/swanlab.py +++ b/swanlab/sdk/internal/probe_python/environment/swanlab.py @@ -14,4 +14,5 @@ @safe.decorator(level="debug", message="Failed to get swanlab environment") def get(ctx: ProbeContext) -> SwanLabSnapshot: """获取 SwanLab 信息快照""" - return SwanLabSnapshot(version=get_swanlab_version(), run_dir=str(ctx.config.run_dir)) + run_dir = str(ctx.config.run_dir) if ctx.config.run_dir is not None else None + return SwanLabSnapshot(version=get_swanlab_version(), run_dir=run_dir) diff --git a/swanlab/sdk/internal/run/__init__.py b/swanlab/sdk/internal/run/__init__.py index 510ade84f..93f7ef8c3 100644 --- a/swanlab/sdk/internal/run/__init__.py +++ b/swanlab/sdk/internal/run/__init__.py @@ -165,15 +165,19 @@ def __init__(self, ctx: RunContext, path: Optional[str] = None): # 启动组件 self._components.start() # 启动硬件监控探针 + # skip_store 下不传递 run_dir,probe 负责将 metadata 信息注入 SaveRecord.payload + probe_run_dir = None if run_settings.core.skip_store else self._ctx.run_dir start_request = DeliverProbeStartRequest( probe_settings=run_settings.to_probe_proto( run_id=run_settings.run.id, - run_dir=self._ctx.run_dir, + run_dir=probe_run_dir, global_system_step=self._ctx.global_system_step, ) ) self._probe.deliver_probe_start(start_request) - console.init(bind_to=self._ctx.debug_dir if self.mode != "disabled" else None) + # skip_store 下没有 debug 目录,诊断日志只输出终端 + bind_to = None if self.mode == "disabled" or run_settings.core.skip_store else self._ctx.debug_dir + console.init(bind_to=bind_to) greeting.welcome(self._ctx, self) # ---------------------------------- diff --git a/swanlab/sdk/internal/run/components/__init__.py b/swanlab/sdk/internal/run/components/__init__.py index 3c24aad7d..a554c4c8a 100644 --- a/swanlab/sdk/internal/run/components/__init__.py +++ b/swanlab/sdk/internal/run/components/__init__.py @@ -139,7 +139,7 @@ def _factory_consumer( def _factory_config(ctx: RunContext, e: EmitterProtocol) -> Config: if ctx.config.settings.mode == "disabled": return create_unbound_run_config() - return create_run_config(ctx.config_file, e.emit) + return create_run_config(ctx.config_file, e.emit, skip_store=ctx.config.settings.core.skip_store) def _factory_terminal(ctx: RunContext, e: EmitterProtocol, init_pid: int) -> TerminalProxyProtocol: diff --git a/swanlab/sdk/internal/run/components/config/__init__.py b/swanlab/sdk/internal/run/components/config/__init__.py index 142f8c37d..b846e398a 100644 --- a/swanlab/sdk/internal/run/components/config/__init__.py +++ b/swanlab/sdk/internal/run/components/config/__init__.py @@ -7,6 +7,7 @@ 生命周期: 未绑定(bindctx 调用前):所有写操作仅保留在内存,不触发 IO 和事件 已绑定(bindctx 调用后):每次写操作 → 全量覆写 config.yaml → 发出 ConfigEvent + (skip_store 下跳过落盘,内容随 ConfigEvent 内联上传云端) 重置(reset 调用后) :清空内存与绑定状态,用于测试隔离或下一次 init 线程安全: @@ -28,7 +29,7 @@ from .helper import revert_config from .parse import parse -from .writer import write_config +from .writer import dump_config, format_config, write_config __all__ = [ "Config", @@ -63,6 +64,7 @@ class Config(MutableMapping): _file: Optional[Path] _emit: Optional[Callable[[ConfigEvent], None]] _bound: bool + _skip_store: bool def __init__(self) -> None: # 直接操作 __dict__ 绕过自定义 __setattr__ @@ -74,6 +76,7 @@ def __init__(self) -> None: "_file": None, # Path | None "_emit": None, # Callable | None "_bound": False, # 是否已绑定 + "_skip_store": False, # 是否跳过 config.yaml 落盘(内容内联上传) } ) @@ -90,26 +93,32 @@ def _set_value(self, key: str, value: Any) -> None: self.__dict__["_seq"] = self._seq + 1 def _flush(self) -> None: - """全量写文件并发出 ConfigEvent。""" + """全量写文件并发出 ConfigEvent(skip_store 下只发事件,不写文件)。""" assert self._file is not None and self._emit is not None, "Config not bound" - write_config(self._file, self._config, self._sort) + content = format_config(self._config, self._sort) + # fs.safe_write 会自动建父目录,写入即静默重建 swanlog 目录树 + if not self._skip_store: + write_config(self._file, dump_config(content)) ts = Timestamp() ts.GetCurrentTime() - self._emit(ConfigEvent(path=self._file, timestamp=ts)) + self._emit(ConfigEvent(path=self._file, timestamp=ts, content=content)) # ------------------------------------------------------------------ # 绑定 / 重置(线程安全) # ------------------------------------------------------------------ - def _bindctx(self, config_file: Path, emit: Callable[[ConfigEvent], None]) -> None: + def _bindctx(self, config_file: Path, emit: Callable[[ConfigEvent], None], skip_store: bool = False) -> None: """ 绑定运行上下文。将内存中已有的 config 全量 flush 到文件, 之后的每次写操作均实时同步。可安全重复调用(幂等)。 + + :param skip_store: 跳过 config.yaml 落盘(仅 online + core.skip_store 合法), + 内容随 ConfigEvent 内联,由消费端填入 SaveRecord.payload """ with _lock: if self._bound: return - self.__dict__.update({"_file": config_file, "_emit": emit, "_bound": True}) + self.__dict__.update({"_file": config_file, "_emit": emit, "_skip_store": skip_store, "_bound": True}) self._flush() def _reset(self) -> None: @@ -117,7 +126,7 @@ def _reset(self) -> None: with _lock: self._config.clear() self._sort.clear() - self.__dict__.update({"_seq": 0, "_file": None, "_emit": None, "_bound": False}) + self.__dict__.update({"_seq": 0, "_file": None, "_emit": None, "_skip_store": False, "_bound": False}) def _snapshot(self) -> tuple[dict, dict, int]: """深拷贝内部状态(config、sort、seq),供 create_run_config 使用。""" @@ -351,12 +360,12 @@ class ConfigProxy(_ConfigProxy, Config): # type: ignore[misc] config = _ConfigProxy() -def create_run_config(config_file: Path, emit: Callable) -> Config: +def create_run_config(config_file: Path, emit: Callable, skip_store: bool = False) -> Config: """从 global config 创建并绑定 per-run config,激活代理。""" global _active_run_config run_cfg = Config() getattr(run_cfg, "_copy_from")(_global_config) - getattr(run_cfg, "_bindctx")(config_file, emit) + getattr(run_cfg, "_bindctx")(config_file, emit, skip_store) _active_run_config = run_cfg return run_cfg diff --git a/swanlab/sdk/internal/run/components/config/writer.py b/swanlab/sdk/internal/run/components/config/writer.py index 102fe8cce..13baa9dd5 100644 --- a/swanlab/sdk/internal/run/components/config/writer.py +++ b/swanlab/sdk/internal/run/components/config/writer.py @@ -12,19 +12,36 @@ from swanlab.sdk.internal.pkg import fs -__all__ = ["write_config"] +__all__ = ["dump_config", "format_config", "write_config"] -def write_config(path: Path, config: dict, sort_map: dict) -> None: +def format_config(config: dict, sort_map: dict) -> dict: """ - 将 config 序列化为 {key: {value, desc, sort}} 格式并写入 YAML 文件。 + 将内部存储的 {key: value} 归一化为后端约定的 {key: {value, desc, sort}} 结构。 + + :param config: 内部存储的原始 {key: value} dict(value 已经过 parse()) + :param sort_map: key → sort index 映射,用于还原插入顺序 + """ + return {key: {"value": value, "desc": "", "sort": sort_map.get(key, 0)} for key, value in config.items()} + + +def dump_config(content: dict) -> str: + """ + 将归一化后的 config 结构序列化为 YAML 文本。 + 落盘(write_config)与 skip_store 下的内联上传共用同一份编码,避免云端 config 结构漂移。 + + :param content: format_config 产出的归一化结构 + """ + return yaml.safe_dump(content, allow_unicode=True, default_flow_style=False) + + +def write_config(path: Path, data: str) -> None: + """ + 将已序列化的 config YAML 文本写入文件,只负责落盘,不负责格式化。 每次调用均全量覆写(INIT 和 PATCH 均如此),消费方以最新文件内容为准。 - :param path: 目标文件路径(config.yaml) - :param config: 内部存储的原始 {key: value} dict(value 已经过 parse()) - :param sort_map: key → sort index 映射,用于还原插入顺序 + :param path: 目标文件路径(config.yaml) + :param data: dump_config 产出的 YAML 文本 """ - formatted = {key: {"value": value, "desc": "", "sort": sort_map.get(key, 0)} for key, value in config.items()} - content = yaml.safe_dump(formatted, allow_unicode=True, default_flow_style=False) - fs.safe_write(path, content) + fs.safe_write(path, data) diff --git a/swanlab/sdk/internal/run/components/consumer/builder/__init__.py b/swanlab/sdk/internal/run/components/consumer/builder/__init__.py index adba64804..d0c838638 100644 --- a/swanlab/sdk/internal/run/components/consumer/builder/__init__.py +++ b/swanlab/sdk/internal/run/components/consumer/builder/__init__.py @@ -6,16 +6,19 @@ """ from functools import singledispatchmethod +from pathlib import Path from typing import Optional from google.protobuf.timestamp_pb2 import Timestamp +from swanlab.proto.swanlab.metric.column.v1.column_pb2 import ColumnType from swanlab.proto.swanlab.metric.data.v1.data_pb2 import MediaRecord from swanlab.proto.swanlab.save.v1.save_pb2 import SaveRecord, SaveType from swanlab.proto.swanlab.terminal.v1.log_pb2 import LogRecord from swanlab.sdk.internal.bus.events import ConfigEvent, FileSaveEvent, LogEvent, ParseResult from swanlab.sdk.internal.context import RunContext, TransformMedia from swanlab.sdk.internal.pkg import adapter, console, fs +from swanlab.sdk.internal.run.components.config.writer import dump_config from swanlab.sdk.internal.run.transforms import ECharts, Scalar, echarts _EchartsType = (echarts.Base, echarts.Table) @@ -43,6 +46,17 @@ def __init__(self, ctx: RunContext): # 由 BackgroundConsumer 单线程调用,无需锁 self._num: int = 0 + def _resolve_media_dir(self, column_type: ColumnType) -> Optional[Path]: + """解析媒体存储目录。 + + skip_store 下返回 None:不创建 media 目录,由 transform 将内容写入 MediaItem.payload。 + """ + if self._ctx.config.settings.core.skip_store: + return None + path = self._ctx.media_dir / adapter.medium[column_type] + fs.safe_mkdir(path) + return path + def _ensure_media_size(self, record: MediaRecord) -> Optional[MediaRecord]: """ 确保媒体记录长度不超过限制,否则截断 @@ -109,8 +123,7 @@ def _(self, value: list, key: str, timestamp: Timestamp, step: int) -> ParseResu if not all(isinstance(item, cls) for item in value): raise TypeError(f"All items in the list must be of the same type {cls.__name__}, got mixed types.") # 3. 构建媒体记录 - path = self._ctx.media_dir / adapter.medium[cls.column_type()] - fs.safe_mkdir(path) + path = self._resolve_media_dir(cls.column_type()) items = [item.transform(step=step, path=path) for item in value] media_record = self._ensure_media_size( cls.build_data_record(key=key, step=step, timestamp=timestamp, data=items) @@ -121,8 +134,7 @@ def _(self, value: list, key: str, timestamp: Timestamp, step: int) -> ParseResu def _(self, value: TransformMedia, key: str, timestamp: Timestamp, step: int) -> ParseResult: """将单个 TransformMediaType 转换为 MediaRecord""" cls = value.__class__ - path = self._ctx.media_dir / adapter.medium[cls.column_type()] - fs.safe_mkdir(path) + path = self._resolve_media_dir(cls.column_type()) values = [value.transform(step=step, path=path)] media_record = self._ensure_media_size( cls.build_data_record(key=key, step=step, timestamp=timestamp, data=values) @@ -130,9 +142,18 @@ def _(self, value: TransformMedia, key: str, timestamp: Timestamp, step: int) -> return media_record, cls # ── 系统元数据 ── - @staticmethod - def build_config(event: ConfigEvent) -> SaveRecord: - """构建 Config Save envelope""" + def build_config(self, event: ConfigEvent) -> SaveRecord: + """构建 Config Save envelope。 + + skip_store 下 config 不落盘,内容按落盘同款 YAML 编码填入 payload; + 未启用时 payload 为 None,Core 按 source_path 读取 config.yaml。 + """ + if self._ctx.config.settings.core.skip_store: + return SaveRecord( + name="config", + type=SaveType.SAVE_TYPE_CONFIG, + payload=dump_config(event.content).encode("utf-8"), + ) return SaveRecord(name="config", source_path=event.path.absolute().as_posix(), type=SaveType.SAVE_TYPE_CONFIG) @staticmethod diff --git a/swanlab/sdk/internal/run/greeting.py b/swanlab/sdk/internal/run/greeting.py index 2d331e61b..17d20922d 100644 --- a/swanlab/sdk/internal/run/greeting.py +++ b/swanlab/sdk/internal/run/greeting.py @@ -25,6 +25,10 @@ def _print_version(): def _print_save_dir(ctx: RunContext): + # skip_store 仅 online 模式合法,此时本地不再创建 swanlog 日志目录,所有数据只传云端. + if ctx.config.settings.core.skip_store: + console.info("⚠️ ", Text("Local record store disabled; data will be uploaded to cloud only", "yellow")) + return console.info("💾 Run data saved at", Text(str(ctx.config.run_dir), "magenta bold")) diff --git a/swanlab/sdk/internal/run/transforms/audio/__init__.py b/swanlab/sdk/internal/run/transforms/audio/__init__.py index 0a6b15326..407c52c9f 100644 --- a/swanlab/sdk/internal/run/transforms/audio/__init__.py +++ b/swanlab/sdk/internal/run/transforms/audio/__init__.py @@ -73,9 +73,19 @@ def __init__(self, data_or_path: AudioDataType, sample_rate: AudioRateType = 441 def column_type(cls) -> ColumnType: return ColumnType.COLUMN_TYPE_AUDIO - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: content = self.buffer.getvalue() sha256 = hashlib.sha256(content).hexdigest() filename = f"{step:03d}-{sha256[:8]}.wav" - fs.safe_write(path / filename, content, mode="wb") - return MediaItem(filename=filename, sha256=sha256, size=len(content), caption=self.caption or "") + item = MediaItem( + filename=filename, + sha256=sha256, + size=len(content), + caption=self.caption or "", + ) + if path is None: + # skip_store:payload 保存二进制字节流。 + item.payload = content + else: + fs.safe_write(path / filename, content, mode="wb") + return item diff --git a/swanlab/sdk/internal/run/transforms/echarts/__init__.py b/swanlab/sdk/internal/run/transforms/echarts/__init__.py index be5ad6830..199c06380 100644 --- a/swanlab/sdk/internal/run/transforms/echarts/__init__.py +++ b/swanlab/sdk/internal/run/transforms/echarts/__init__.py @@ -7,6 +7,7 @@ import hashlib from pathlib import Path +from typing import Optional from swanlab.proto.swanlab.metric.column.v1.column_pb2 import ColumnType from swanlab.proto.swanlab.metric.data.v1.data_pb2 import MediaItem @@ -46,11 +47,21 @@ def __init__(self, chart: EChartsDataType, caption: CaptionType = None): def column_type(cls) -> ColumnType: return ColumnType.COLUMN_TYPE_ECHARTS - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: content_encode = self.json_content.encode("utf-8") sha256 = hashlib.sha256(content_encode).hexdigest() filename = f"{step:03d}-{sha256[:8]}.json" # safe_write 默认 mode="w"(文本模式),Windows 上会将 \n 转换为 \r\n。而 ECharts.transform 中 # json_content.encode("utf-8") 计算的 size 和 sha256 是基于 \n 的。 - fs.safe_write(path / filename, content_encode, mode="wb") - return MediaItem(filename=filename, sha256=sha256, size=len(content_encode), caption=self.caption or "") + item = MediaItem( + filename=filename, + sha256=sha256, + size=len(content_encode), + caption=self.caption or "", + ) + if path is None: + # skip_store:内容随 payload 走;空内容也赋值以保留 presence(与缺失 payload 区分)。 + item.payload = content_encode + else: + fs.safe_write(path / filename, content_encode, mode="wb") + return item diff --git a/swanlab/sdk/internal/run/transforms/html/__init__.py b/swanlab/sdk/internal/run/transforms/html/__init__.py index 9baf2bfce..f6574e6a5 100644 --- a/swanlab/sdk/internal/run/transforms/html/__init__.py +++ b/swanlab/sdk/internal/run/transforms/html/__init__.py @@ -94,10 +94,20 @@ def __init__(self, data: HtmlDataType, caption: CaptionType = None): def column_type(cls) -> ColumnType: return ColumnType.COLUMN_TYPE_HTML - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: content_encode = self.content.encode("utf-8") sha256 = hashlib.sha256(content_encode).hexdigest() filename = f"{step:03d}-{sha256[:8]}.html" # 复用已编码的 bytes 直接以二进制写入, 避免 safe_write 内部对 str 再做一次 UTF-8 编码 (双重编码) - fs.safe_write(path / filename, content_encode, mode="wb") - return MediaItem(filename=filename, sha256=sha256, size=len(content_encode), caption=self.caption or "") + item = MediaItem( + filename=filename, + sha256=sha256, + size=len(content_encode), + caption=self.caption or "", + ) + if path is None: + # skip_store:内容随 payload 走;空内容也赋值以保留 presence(与缺失 payload 区分)。 + item.payload = content_encode + else: + fs.safe_write(path / filename, content_encode, mode="wb") + return item diff --git a/swanlab/sdk/internal/run/transforms/image/__init__.py b/swanlab/sdk/internal/run/transforms/image/__init__.py index a38c598db..d148ec6d1 100644 --- a/swanlab/sdk/internal/run/transforms/image/__init__.py +++ b/swanlab/sdk/internal/run/transforms/image/__init__.py @@ -155,9 +155,19 @@ def __init__( def column_type(cls) -> ColumnType: return ColumnType.COLUMN_TYPE_IMAGE - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: content = self.buffer.getvalue() sha256 = hashlib.sha256(content).hexdigest() filename = f"{step:03d}-{sha256[:8]}.{self.file_type}" - fs.safe_write(path / filename, content, mode="wb") - return MediaItem(filename=filename, sha256=sha256, size=len(content), caption=self.caption or "") + item = MediaItem( + filename=filename, + sha256=sha256, + size=len(content), + caption=self.caption or "", + ) + if path is None: + # skip_store:payload 保存二进制字节流。 + item.payload = content + else: + fs.safe_write(path / filename, content, mode="wb") + return item diff --git a/swanlab/sdk/internal/run/transforms/molecule/__init__.py b/swanlab/sdk/internal/run/transforms/molecule/__init__.py index 8e32031cb..b1bd6f7f2 100644 --- a/swanlab/sdk/internal/run/transforms/molecule/__init__.py +++ b/swanlab/sdk/internal/run/transforms/molecule/__init__.py @@ -212,9 +212,19 @@ def from_mol_file(cls, path, caption: CaptionType = None) -> "Molecule": def column_type(cls) -> ColumnType: return ColumnType.COLUMN_TYPE_MOLECULE - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: content = self.buffer.getvalue() sha256 = hashlib.sha256(content).hexdigest() filename = f"{step:03d}-{sha256[:8]}.pdb" - fs.safe_write(path / filename, content, mode="wb") - return MediaItem(filename=filename, sha256=sha256, size=len(content), caption=self.caption or "") + item = MediaItem( + filename=filename, + sha256=sha256, + size=len(content), + caption=self.caption or "", + ) + if path is None: + # skip_store:payload 保存二进制字节流。 + item.payload = content + else: + fs.safe_write(path / filename, content, mode="wb") + return item diff --git a/swanlab/sdk/internal/run/transforms/object3d/__init__.py b/swanlab/sdk/internal/run/transforms/object3d/__init__.py index b766a94da..8a7e00d9d 100644 --- a/swanlab/sdk/internal/run/transforms/object3d/__init__.py +++ b/swanlab/sdk/internal/run/transforms/object3d/__init__.py @@ -224,12 +224,22 @@ def from_point_data(cls, points, boxes=None, caption: CaptionType = None) -> "Ob def column_type(cls) -> ColumnType: return ColumnType.COLUMN_TYPE_OBJECT3D - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: content = self.buffer.getvalue() sha256 = hashlib.sha256(content).hexdigest() filename = f"{step:03d}-{sha256[:8]}.{self.file_type}" - fs.safe_write(path / filename, content, mode="wb") - return MediaItem(filename=filename, sha256=sha256, size=len(content), caption=self.caption or "") + item = MediaItem( + filename=filename, + sha256=sha256, + size=len(content), + caption=self.caption or "", + ) + if path is None: + # skip_store:payload 保存二进制字节流。 + item.payload = content + else: + fs.safe_write(path / filename, content, mode="wb") + return item # ---------- 辅助函数 ---------- diff --git a/swanlab/sdk/internal/run/transforms/text/__init__.py b/swanlab/sdk/internal/run/transforms/text/__init__.py index 229939f3e..44e7b37b9 100644 --- a/swanlab/sdk/internal/run/transforms/text/__init__.py +++ b/swanlab/sdk/internal/run/transforms/text/__init__.py @@ -7,6 +7,7 @@ import hashlib from pathlib import Path +from typing import Optional from swanlab.proto.swanlab.metric.column.v1.column_pb2 import ColumnType from swanlab.proto.swanlab.metric.data.v1.data_pb2 import MediaItem @@ -27,7 +28,7 @@ def __init__(self, content: TextDataType, caption: CaptionType = None): def column_type(cls) -> ColumnType: return ColumnType.COLUMN_TYPE_TEXT - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: content_encode = self.content.encode() # 计算 sha256 sha256 = hashlib.sha256(content_encode).hexdigest() @@ -36,5 +37,15 @@ def transform(self, *, step: int, path: Path) -> MediaItem: filename = f"{step:03d}-{sha256[:8]}.txt" # 复用已编码的 bytes 直接以二进制写入: 既避免 safe_write 对 str 再做一次 UTF-8 编码, # 也保证 Windows 下文件内容 (LF) 与上方 sha256/size 计算结果一致 - fs.safe_write(path / filename, content_encode, mode="wb") - return MediaItem(filename=filename, sha256=sha256, size=len(content_encode), caption=self.caption) + item = MediaItem( + filename=filename, + sha256=sha256, + size=len(content_encode), + caption=self.caption, + ) + if path is None: + # skip_store:内容随 payload 走;空内容也赋值以保留 presence(与缺失 payload 区分)。 + item.payload = content_encode + else: + fs.safe_write(path / filename, content_encode, mode="wb") + return item diff --git a/swanlab/sdk/internal/run/transforms/video/__init__.py b/swanlab/sdk/internal/run/transforms/video/__init__.py index 63d2ff201..26763952c 100644 --- a/swanlab/sdk/internal/run/transforms/video/__init__.py +++ b/swanlab/sdk/internal/run/transforms/video/__init__.py @@ -102,9 +102,19 @@ def column_type(cls) -> ColumnType: # TODO: 服务端支持 VIDEO 列类型后,改回 ColumnType.COLUMN_TYPE_VIDEO return ColumnType.COLUMN_TYPE_IMAGE - def transform(self, *, step: int, path: Path) -> MediaItem: + def transform(self, *, step: int, path: Optional[Path]) -> MediaItem: content = self.buffer.getvalue() sha256 = hashlib.sha256(content).hexdigest() filename = f"{step:03d}-{sha256[:8]}.{self.format}" - fs.safe_write(path / filename, content, mode="wb") - return MediaItem(filename=filename, sha256=sha256, size=len(content), caption=self.caption or "") + item = MediaItem( + filename=filename, + sha256=sha256, + size=len(content), + caption=self.caption or "", + ) + if path is None: + # skip_store:payload 保存二进制字节流。 + item.payload = content + else: + fs.safe_write(path / filename, content, mode="wb") + return item diff --git a/swanlab/sdk/internal/settings/__init__.py b/swanlab/sdk/internal/settings/__init__.py index 9caa0e6d7..8ad486102 100644 --- a/swanlab/sdk/internal/settings/__init__.py +++ b/swanlab/sdk/internal/settings/__init__.py @@ -272,6 +272,18 @@ def load_api_key(self) -> "Settings": fields_set.add("web_host") return self + @model_validator(mode="after") + def validate_skip_store(self) -> "Settings": + """ + ``core.skip_store`` 仅 online mode 合法。 + """ + if self.mode != "online" and self.core.skip_store: + raise ValueError( + f"core.skip_store=true is only supported in online mode, but mode is '{self.mode}'. " + "Set mode='online' or core.skip_store=false." + ) + return self + project: ProjectSettings = Field(default_factory=ProjectSettings) """ Configuration for the project of this SwanLab run. @@ -326,6 +338,7 @@ def to_core_proto(self, run_id: str, run_dir: Path) -> CoreSettingsPb: save_size=self.core.save_size, save_part=self.core.save_part, save_batch=self.core.save_batch, + skip_store=self.core.skip_store, ) def to_probe_proto( @@ -344,6 +357,7 @@ def to_probe_proto( monitor=self.probe.monitor, monitor_interval=self.probe.monitor_interval, monitor_disk_dir=str(self.probe.monitor_disk_dir.absolute()), + skip_store=self.core.skip_store, ) if run_id is not None: ps.run_id = run_id diff --git a/swanlab/sdk/internal/settings/core.py b/swanlab/sdk/internal/settings/core.py index 463c752b7..664699ec4 100644 --- a/swanlab/sdk/internal/settings/core.py +++ b/swanlab/sdk/internal/settings/core.py @@ -42,4 +42,13 @@ class CoreSettings(BaseModel): """ Maximum number of files per save upload batch. Default 100. """ + skip_store: bool = Field(default=False) + """ + Skip all SDK-owned local files and upload to the cloud only (requires ``online`` mode). + + Media contents and internal texts (config/metadata/requirements/conda) are inlined + into records and uploaded directly; ``run.save()`` files are read from their original + path without local links. + ``swanlab sync`` / ``swanlab watch`` do not apply; a crashed run cannot be recovered. + """ model_config = ConfigDict(frozen=True) diff --git a/tests/benchmark/sdk/cmd/bench_skip_store_e2e.py b/tests/benchmark/sdk/cmd/bench_skip_store_e2e.py new file mode 100644 index 000000000..0ff9038a7 --- /dev/null +++ b/tests/benchmark/sdk/cmd/bench_skip_store_e2e.py @@ -0,0 +1,444 @@ +""" +@author: nexisato +@file: bench_skip_store_e2e.py +@time: 2026/9/11 +@description: core.skip_store 端到端基准测试(mock HTTP,对比吞吐量与延时) + +在 online 模式下完整跑一遍 ``swanlab.init → log/log_image/save → finish``, +所有 HTTP 端点用 responses mock,对比 ``core.skip_store`` 开关对**用户线程延时** +与**端到端吞吐量**的影响。 + +被测对象是真实运行时链路:事件总线 → BackgroundConsumer → CorePython → 本地 store ++ Transport → HTTP 上传,而非单独的存储模块。 + +工作负载(每个场景一致): + - 标量:STEPS 步 × KEYS 个 key,逐 step 调用 ``run.log`` + - 媒体:每隔 IMAGE_EVERY 步调用一次 ``run.log_image``(小图),共约 STEPS/IMAGE_EVERY 张 + - 文件保存:SAVE_COUNT 个 CUSTOM save,policy=end(生成阶段只登记,finish 时上传) + +关注指标: + 1. 生产阶段墙钟耗时与吞吐量(records/s) + 2. finish 排空 + 上传耗时 (ms) + 3. 端到端墙钟耗时 (s) 与吞吐量 (records/s) + 4. run.log 主线程单次调用延迟 mean / p50 / p95 / p99 (μs) + 5. 本地产物(run-*.swanlab 字节数、media 文件数、files 软链接数) + +说明:run.log_image / run.save 的单次延时不做对比——它们会被后台 +BackgroundConsumer / Transport 的 GIL 竞争严重污染,无法反映用户线程成本; +媒体与 save 的收益通过 finish 排空耗时与总墙钟体现。为使 producer / finish +两阶段边界确定,``record_interval`` 设为很大值,上传统一发生在 finish。 + +每个场景在独立子进程中运行(responses mock + 全局单例无法安全复用), +父进程取多次运行的最优值后汇总对比。 + +用法: + uv run pytest tests/benchmark/sdk/cmd/bench_skip_store_e2e.py -v -s + + # 独立脚本(可调参数) + uv run python tests/benchmark/sdk/cmd/bench_skip_store_e2e.py --steps 500 --keys 20 +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from typing import Any, Dict, List, Optional + +RESULT_MARK = "##BENCH_JSON## " + +# =========================================================================== +# Mock HTTP 常量(与 tests/unit/sdk/cmd/init/test_init_e2e.py 保持一致) +# =========================================================================== + +API_HOST = "https://api.fake.swanlab.cn" +WEB_HOST = "https://test.swanlab.cn" +USERNAME = "test-user" +PROJECT = "test-project" +RUN_ID = "test-run-id" +EXPERIMENT_CUID = "test-experiment-cuid" +API_KEY = "test-api-key" + +# =========================================================================== +# 默认负载参数 +# =========================================================================== + +STEPS = 1000 +KEYS = 100 +IMAGE_EVERY = 4 +IMAGE_SIZE = 64 # 64x64x3 uint8 +SAVE_COUNT = 50 +SAVE_SIZE = 4 * 1024 + +# 让 Transport 不在生产阶段中途 drain,保证 producer / finish 两阶段边界确定, +# 生产阶段只体现“事件入队 + 消费线程本地 store”的成本,finish 阶段统一上传。 +RECORD_INTERVAL = 3600.0 +REPEATS = 2 + + +# =========================================================================== +# 统计辅助 +# =========================================================================== + + +def percentile(sorted_data: List[float], pct: float) -> float: + if not sorted_data: + return 0.0 + idx = int(len(sorted_data) * pct / 100.0) + return sorted_data[min(idx, len(sorted_data) - 1)] + + +def _latency_stats(latencies_us: List[float]) -> Dict[str, float]: + if not latencies_us: + return {"mean": 0.0, "p50": 0.0, "p95": 0.0, "p99": 0.0} + s = sorted(latencies_us) + return { + "mean": round(sum(s) / len(s), 2), + "p50": round(percentile(s, 50), 2), + "p95": round(percentile(s, 95), 2), + "p99": round(percentile(s, 99), 2), + } + + +# =========================================================================== +# HTTP Mock(动态预签名) +# =========================================================================== + + +def _install_http_mocks(rsps) -> None: + """注册 init + 指标 + 媒体 + save 全流程端点,预签名 URL 按请求动态返回。""" + import responses as responses_lib + + def ok(**extra): + body = {"message": "ok"} + body.update(extra) + return (200, {"Content-Type": "application/json"}, json.dumps(body)) + + def presigned_callback(request): + payload = json.loads(request.body) + urls = [f"https://storage.fake.swanlab.cn/media/{i}" for i in range(len(payload["paths"]))] + return (200, {"Content-Type": "application/json"}, json.dumps({"urls": urls})) + + def prepare_callback(request): + payload = json.loads(request.body) + count = len(payload.get("files", [])) + urls = [f"https://storage.fake.swanlab.cn/save/{i}" for i in range(count)] + return (200, {"Content-Type": "application/json"}, json.dumps({"urls": urls})) + + rsps.add( + responses_lib.POST, + f"{API_HOST}/api/login/api_key", + json={"sid": "mock-sid", "expiredAt": "2099-12-31T23:59:59.000Z", "userInfo": {"username": USERNAME}}, + status=200, + ) + rsps.add( + responses_lib.POST, + f"{API_HOST}/api/projects/{USERNAME}", + json={"name": PROJECT, "username": USERNAME, "path": f"/{USERNAME}/{PROJECT}"}, + status=201, + ) + rsps.add( + responses_lib.GET, + f"{API_HOST}/api/project/{USERNAME}/{PROJECT}", + json={ + "cuid": "test-project-cuid", + "name": PROJECT, + "version": 1, + "group": {"username": USERNAME}, + "username": USERNAME, + "path": f"/{USERNAME}/{PROJECT}", + "visibility": "PRIVATE", + "_count": {"experiments": 0, "contributors": 1, "collaborators": 0, "clones": 0}, + }, + status=200, + ) + rsps.add( + responses_lib.POST, + f"{API_HOST}/api/project/{USERNAME}/{PROJECT}/experiment", + json={"cuid": EXPERIMENT_CUID, "slug": RUN_ID, "name": "test-experiment"}, + status=201, + ) + rsps.add( + responses_lib.PUT, + f"{API_HOST}/api/project/{USERNAME}/{PROJECT}/runs/{EXPERIMENT_CUID}/state", + json={"message": "ok"}, + status=200, + ) + rsps.add( + responses_lib.PUT, + f"{API_HOST}/api/project/{USERNAME}/{PROJECT}/runs/{EXPERIMENT_CUID}/profile", + json={"message": "ok"}, + status=200, + ) + rsps.add( + responses_lib.POST, f"{API_HOST}/api/projects/{USERNAME}/{PROJECT}/series", json={"message": "ok"}, status=200 + ) + rsps.add(responses_lib.POST, f"{API_HOST}/api/house/metrics", json={"message": "ok"}, status=200) + rsps.add( + responses_lib.POST, + f"{API_HOST}/api/house/experiments/{EXPERIMENT_CUID}/heartbeat", + json={"message": "ok"}, + status=200, + ) + # 媒体:动态预签名 + 对象存储 PUT + rsps.add_callback(responses_lib.POST, f"{API_HOST}/api/resources/presigned/put", callback=presigned_callback) + rsps.add(responses_lib.PUT, re.compile(r"https://storage\.fake\.swanlab\.cn/media/.*"), body="", status=200) + # 文件保存:动态 prepare + 对象存储 PUT + complete + rsps.add_callback( + responses_lib.POST, f"{API_HOST}/api/experiment/{EXPERIMENT_CUID}/files/prepare", callback=prepare_callback + ) + rsps.add( + responses_lib.POST, + f"{API_HOST}/api/experiment/{EXPERIMENT_CUID}/files/complete", + json={"message": "ok"}, + status=201, + ) + rsps.add(responses_lib.PUT, re.compile(r"https://storage\.fake\.swanlab\.cn/save/.*"), body="", status=200) + + +# =========================================================================== +# 子进程 Worker +# =========================================================================== + + +def run_worker(skip_store: bool, steps: int, keys: int, save_count: int) -> None: + import numpy as np + import responses as responses_lib + + import swanlab + from swanlab.sdk.cmd.login import login_raw + from swanlab.sdk.cmd.merge_settings import merge_settings + + tmp_dir = tempfile.mkdtemp(prefix="swanlab_e2e_bench_") + src_dir = os.path.join(tmp_dir, "src") + os.makedirs(src_dir, exist_ok=True) + save_files = [] + for i in range(save_count): + path = os.path.join(src_dir, f"ckpt_{i:03d}.bin") + with open(path, "wb") as f: + f.write(bytes([i % 256]) * SAVE_SIZE) + save_files.append(path) + + finish_ms = 0.0 + total_s = 0.0 + run_file_bytes = 0 + media_files = 0 + save_links = 0 + result: Dict[str, Any] = {} + + try: + with responses_lib.RequestsMock(assert_all_requests_are_fired=False) as rsps: + _install_http_mocks(rsps) + merge_settings({"api_host": API_HOST, "web_host": WEB_HOST, "probe": {"monitor": False}}) + login_raw(api_key=API_KEY, host=API_HOST, save=False, print_welcome=False) + + settings = swanlab.Settings( + core=swanlab.Settings.Core(skip_store=skip_store, record_interval=RECORD_INTERVAL) + ) + run = swanlab.init(mode="online", project=PROJECT, log_dir=tmp_dir, settings=settings) + + log_lat: List[float] = [] + num_image = 0 + + t_total_start = time.perf_counter() + + # ---- 标量 + 媒体生产阶段 ---- + for s in range(steps): + data = {f"k{i}": 0.5 + (s % 1000) * 1e-4 for i in range(keys)} + t0 = time.perf_counter() + run.log(data, step=s) + log_lat.append((time.perf_counter() - t0) * 1e6) + + if s % IMAGE_EVERY == 0: + img = np.zeros((IMAGE_SIZE, IMAGE_SIZE, 3), dtype=np.uint8) + run.log_image(key="img", data=img, step=s) + num_image += 1 + + t_producer_end = time.perf_counter() + + # ---- 文件保存(policy=end,仅登记,finish 上传)---- + for path in save_files: + run.save(path, base_path=src_dir, policy="end") + + # ---- finish:排空 consumer + transport ---- + t_finish_start = time.perf_counter() + run.finish() + finish_ms = (time.perf_counter() - t_finish_start) * 1e6 / 1e3 + + t_total_end = time.perf_counter() + + # 本地产物统计(非 skip 才有) + run_dir = run._ctx.run_dir + for p in run_dir.glob("run-*.swanlab"): + run_file_bytes += p.stat().st_size + media_dir = run_dir / "media" + if media_dir.exists(): + media_files = sum(1 for p in media_dir.rglob("*") if p.is_file()) + files_dir = run_dir / "files" + if files_dir.exists(): + save_links = sum(1 for p in files_dir.rglob("*") if p.is_symlink()) + + total_s = t_total_end - t_total_start + + result = { + "skip_store": skip_store, + "steps": steps, + "keys": keys, + "scalar_records": steps * keys, + "media_records": num_image, + "save_files": save_count, + "producer_s": round(t_producer_end - t_total_start, 4), + "total_s": round(total_s, 4), + "finish_ms": round(finish_ms, 2), + "producer_rec_per_s": round(steps * keys / (t_producer_end - t_total_start), 1), + "e2e_rec_per_s": round(steps * keys / total_s, 1), + "run_file_bytes": run_file_bytes, + "media_files": media_files, + "save_links": save_links, + "log": _latency_stats(log_lat), + } + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + assert result, "worker produced no result" + print(RESULT_MARK + json.dumps(result), flush=True) + + +def spawn_case(skip_store: bool, steps: int, keys: int, save_count: int) -> Dict[str, Any]: + cmd = [ + sys.executable, + os.path.abspath(__file__), + "--worker", + "--skip-store" if skip_store else "--persist", + "--steps", + str(steps), + "--keys", + str(keys), + "--save-count", + str(save_count), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + for line in proc.stdout.splitlines(): + if line.startswith(RESULT_MARK): + return json.loads(line[len(RESULT_MARK) :]) + sys.stderr.write(proc.stdout) + sys.stderr.write(proc.stderr) + raise RuntimeError(f"Worker failed: skip_store={skip_store}, rc={proc.returncode}") + + +# =========================================================================== +# 报告 +# =========================================================================== + + +def best_of(skip_store: bool, steps: int, keys: int, save_count: int, repeats: int) -> Dict[str, Any]: + """重复运行并返回 total_s 最优的一次(降低调度噪声)。""" + best: Optional[Dict[str, Any]] = None + for _ in range(repeats): + r = spawn_case(skip_store=skip_store, steps=steps, keys=keys, save_count=save_count) + if best is None or r["total_s"] < best["total_s"]: + best = r + assert best is not None + return best + + +def _print_report(persist: Dict[str, Any], skip: Dict[str, Any]) -> None: + print("\n" + "=" * 92) + print(" SwanLab Benchmark: core.skip_store end-to-end (online + mocked HTTP)") + print("=" * 92) + print( + f" workload: {persist['steps']} steps x {persist['keys']} keys " + f"= {persist['scalar_records']:,} scalars, {persist['media_records']} media, " + f"{persist['save_files']} saves" + ) + print("-" * 92) + print(f" {'metric':<28}{'persist':>18}{'skip_store':>18}{'delta':>20}") + print("-" * 92) + + def row(label: str, a: Any, b: Any, lower_better: bool = True) -> None: + if isinstance(a, float) and isinstance(b, float) and a != 0: + pct = (a - b) / a * 100.0 + delta = f"{pct:+.1f}%" + else: + delta = "-" + print(f" {label:<28}{str(a):>18}{str(b):>18}{delta:>20}") + + row("producer (s)", persist["producer_s"], skip["producer_s"]) + row("total wall (s)", persist["total_s"], skip["total_s"]) + row("finish drain (ms)", persist["finish_ms"], skip["finish_ms"]) + row("producer throughput (rec/s)", persist["producer_rec_per_s"], skip["producer_rec_per_s"]) + row("e2e throughput (rec/s)", persist["e2e_rec_per_s"], skip["e2e_rec_per_s"]) + print("-" * 92) + print(" run.log call (us) [主线程耗时;标量为主,不含后台上传]") + for stat in ("mean", "p50", "p95", "p99"): + print(f" {' ' + stat:<28}{persist['log'][stat]:>18}{skip['log'][stat]:>18}") + print("-" * 92) + row("run-*.swanlab bytes", persist["run_file_bytes"], skip["run_file_bytes"]) + row("media files", persist["media_files"], skip["media_files"]) + row("save links", persist["save_links"], skip["save_links"]) + print("-" * 92) + print(" note: producer 阶段含 BackgroundConsumer 的本地 store 工作。skip_store 把它从") + print(" 磁盘 I/O 变成内存操作,主线程与消费者线程的 GIL 竞争可能反而拉长 producer") + print(" 墙钟;真正的收益体现在 finish 排空耗时与端到端总耗时上。") + print("=" * 92) + + +# =========================================================================== +# Pytest 入口 +# =========================================================================== + + +def test_bench_skip_store_e2e(): + """online + mock HTTP 下端到端对比 skip_store 的吞吐量与延时。""" + persist = best_of(skip_store=False, steps=STEPS, keys=KEYS, save_count=SAVE_COUNT, repeats=REPEATS) + skip = best_of(skip_store=True, steps=STEPS, keys=KEYS, save_count=SAVE_COUNT, repeats=REPEATS) + + _print_report(persist, skip) + + # 正确性:persist 有本地产物,skip 完全没有 + assert persist["run_file_bytes"] > 0 + assert persist["media_files"] > 0 + assert persist["save_links"] > 0 + assert skip["run_file_bytes"] == 0 + assert skip["media_files"] == 0 + assert skip["save_links"] == 0 + # 场景一致性 + assert persist["scalar_records"] == skip["scalar_records"] + assert persist["media_records"] == skip["media_records"] + + +# =========================================================================== +# CLI +# =========================================================================== + + +def main() -> None: + parser = argparse.ArgumentParser(description="SwanLab: core.skip_store end-to-end benchmark") + parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--skip-store", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--persist", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--steps", type=int, default=STEPS) + parser.add_argument("--keys", type=int, default=KEYS) + parser.add_argument("--save-count", type=int, default=SAVE_COUNT) + parser.add_argument("--repeats", type=int, default=REPEATS) + args = parser.parse_args() + + if args.worker: + run_worker(skip_store=args.skip_store, steps=args.steps, keys=args.keys, save_count=args.save_count) + return + + persist = best_of( + skip_store=False, steps=args.steps, keys=args.keys, save_count=args.save_count, repeats=args.repeats + ) + skip = best_of(skip_store=True, steps=args.steps, keys=args.keys, save_count=args.save_count, repeats=args.repeats) + _print_report(persist, skip) + + +if __name__ == "__main__": + main() diff --git a/tests/benchmark/sdk/internal/core_python/store/bench_store_skip.py b/tests/benchmark/sdk/internal/core_python/store/bench_store_skip.py new file mode 100644 index 000000000..65963d507 --- /dev/null +++ b/tests/benchmark/sdk/internal/core_python/store/bench_store_skip.py @@ -0,0 +1,330 @@ +""" +@author: nexisato +@file: bench_store_skip.py +@time: 2026/9/11 +@description: core.skip_store 本地持久化开销基准测试 + +对比 online mode 落盘(skip_store=False)与完全跳过持久化(skip_store=True) +在相同写入负载下的性能,量化“跳过 protobuf 序列化 + 本地落盘”带来的收益。 + +负载规模与 bench_metrics_steps.py 对齐:200 key × 5000 step +(总记录数亦与 bench_store_fsync.py 的 NUM_RECORDS 一致);额外叠加: + - 1,000 条 media,每条 32 KiB) + - 100 个 save file(源文件各 32 KiB) + +三类记录在两种模式下均按生产路径构造,差异即 skip_store 真正省下的本地工作: + 标量: + - skip_store=False: 逐条 ``Record.SerializeToString()`` 后写入 LevelDB log + - skip_store=True : 仅累加未持久化计数,不序列化 + 媒体(对应 Image.transform): + - skip_store=False: 32 KiB 写入 ``media/image/``(``fs.safe_write`` 含 fsync), + MediaItem 仅带 filename/sha256/size + - skip_store=True : 字节流内联进 ``MediaItem.payload``,不落盘 + 文件保存(对应 Core._handle_custom_save): + - skip_store=False: 在 ``files/`` 建立软链接镜像并填充 target_path + - skip_store=True : 不建镜像,直接引用 source_path + +关注指标: + 1. 各类型 persist / skip 总耗时与加速比 + 2. 标量吞吐量 (rec/s),媒体写入带宽 (MiB/s) + 3. 数据完整性(persist 标量可完整回读、媒体文件数与 save 镜像数正确;skip 不落盘) + +用法: + uv run pytest tests/benchmark/sdk/internal/core_python/store/bench_store_skip.py -v -s +""" + +import hashlib +import time +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from swanlab.proto.swanlab.metric.column.v1.column_pb2 import ColumnType +from swanlab.proto.swanlab.metric.data.v1.data_pb2 import ( + MediaItem, + MediaRecord, + MediaValue, + ScalarRecord, + ScalarValue, +) +from swanlab.proto.swanlab.record.v1.record_pb2 import Record +from swanlab.proto.swanlab.save.v1.save_pb2 import SavePolicy, SaveRecord, SaveType +from swanlab.sdk.internal.core_python.context import CoreConfig, CoreContext +from swanlab.sdk.internal.core_python.core import CorePython +from swanlab.sdk.internal.core_python.store import DataStoreReader, DataStoreWriter +from swanlab.sdk.internal.core_python.watcher import create_save_links +from swanlab.sdk.internal.pkg import adapter, fs + +# =========================================================================== +# 参数配置 +# =========================================================================== + +# 与 bench_metrics_steps.py 对齐:200 key × 5000 step,共 1,000,000 条记录 +# (总记录数同时与 bench_store_fsync.py 的 NUM_RECORDS = 1,000,000 一致) +NUM_KEYS = 200 +NUM_STEPS = 5_000 +NUM_RECORDS = NUM_KEYS * NUM_STEPS +BATCH_SIZE = 100 # 与 BackgroundConsumer 的 scalar 批大小一致 +REPEATS = 3 # 每场景重复次数,取最优以降低噪声 + +MEDIA_COUNT = 1_000 +MEDIA_SIZE = 32 * 1024 # 每条媒体 32 KiB +SAVE_COUNT = 100 +SAVE_SIZE = 32 * 1024 # 每个 CUSTOM save 源文件 32 KiB + + +# =========================================================================== +# 记录构造 +# =========================================================================== + + +def _make_scalar_records() -> List[Record]: + """预生成 step × key 的标量 Record,避免把构建开销算进持久化基准。 + + key 命名与 bench_metrics_steps.py 的 ``k{i}`` 一致,便于横向对比。 + """ + records: List[Record] = [] + num = 0 + for step in range(1, NUM_STEPS + 1): + for k in range(NUM_KEYS): + num += 1 + records.append( + Record( + num=num, + scalar=ScalarRecord( + key=f"k{k}", + step=step, + type=ColumnType.COLUMN_TYPE_SCALAR, + value=ScalarValue(number=0.5 + (step % 1000) * 1e-4), + ), + ) + ) + return records + + +def _make_media_records(skip_store: bool) -> Tuple[List[Record], List[Tuple[str, bytes]]]: + """构造 1,000 条 32 KiB 的 Image media。 + + 返回 (records, files);``files`` 仅在 skip_store=False 时非空,为需落盘的 + (filename, content)。skip_store=True 时 content 内联进 MediaItem.payload。 + """ + records: List[Record] = [] + files: List[Tuple[str, bytes]] = [] + for i in range(MEDIA_COUNT): + content = bytes([i % 256]) * MEDIA_SIZE + sha256 = hashlib.sha256(content).hexdigest() + filename = f"{i:04d}-{sha256[:8]}.png" + item = MediaItem(filename=filename, sha256=sha256, size=len(content), caption="") + if skip_store: + item.payload = content + else: + files.append((filename, content)) + records.append( + Record( + num=NUM_RECORDS + i + 1, + media=MediaRecord( + key=f"media/img_{i % 10}", + step=i + 1, + type=ColumnType.COLUMN_TYPE_IMAGE, + value=MediaValue(items=[item]), + ), + ) + ) + return records, files + + +def _make_save_entries(source_paths: List[Path]) -> List[SaveRecord]: + """构造 CUSTOM SaveRecord;CUSTOM 的 payload 恒空,只引用 source_path。""" + return [ + SaveRecord( + name=f"checkpoints/model_{i:03d}.bin", + source_path=str(src), + policy=SavePolicy.SAVE_POLICY_END, + type=SaveType.SAVE_TYPE_CUSTOM, + ) + for i, src in enumerate(source_paths) + ] + + +def _to_save_envelopes(saves: List[SaveRecord]) -> List[Record]: + """将 SaveRecord 包成 Record envelope(target_path 由 create_save_links 填充后再拷贝)。""" + total = NUM_RECORDS + MEDIA_COUNT + return [Record(num=total + i + 1, save=save) for i, save in enumerate(saves)] + + +def _make_source_files(root: Path) -> List[Path]: + """创建 SAVE_COUNT 个 32 KiB 源文件(基准计时之外,仅作为 save 的引用目标)。""" + src_dir = root / "sources" + src_dir.mkdir(parents=True, exist_ok=True) + paths: List[Path] = [] + for i in range(SAVE_COUNT): + path = src_dir / f"model_{i:03d}.bin" + path.write_bytes(bytes([i % 256]) * SAVE_SIZE) + paths.append(path) + return paths + + +# =========================================================================== +# 持久化执行 +# =========================================================================== + + +def _make_core(root: Path, skip_store: bool, tag: str) -> CorePython: + """构造仅用于持久化路径的 CorePython(不启动 transport / heartbeat)。""" + run_dir = root / f"{tag}-skip={skip_store}" + run_dir.mkdir(parents=True) + core = CorePython(mode="online") + core._ctx = CoreContext( + config=CoreConfig( + run_id=f"bench-{tag}", + run_dir=run_dir, + section_rule=0, + record_batch=BATCH_SIZE, + record_interval=5.0, + save_split=100 * 1024 * 1024, + save_size=50 * 1024 * 1024, + save_part=32 * 1024 * 1024, + save_batch=100, + skip_store=skip_store, + ) + ) + core._store = DataStoreWriter(skip=skip_store) + core._store.open(str(core._ctx.run_file)) + return core + + +def _write_records(core: CorePython, records: List[Record]) -> None: + """按 BATCH_SIZE 分批调用生产路径 _store_records。""" + for offset in range(0, len(records), BATCH_SIZE): + core._store_records(records[offset : offset + BATCH_SIZE]) + + +def _run_scalar(root: Path, skip_store: bool) -> Tuple[float, CorePython]: + records = _make_scalar_records() + best = float("inf") + core: Optional[CorePython] = None + for i in range(REPEATS): + core = _make_core(root, skip_store, f"scalar-{i}") + start = time.perf_counter() + _write_records(core, records) + best = min(best, time.perf_counter() - start) + assert core._store is not None + core._store.close() + assert core is not None + return best, core + + +def _run_media(root: Path, skip_store: bool) -> Tuple[float, CorePython]: + best = float("inf") + core: Optional[CorePython] = None + for i in range(REPEATS): + core = _make_core(root, skip_store, f"media-{i}") + records, files = _make_media_records(skip_store) + start = time.perf_counter() + if not skip_store: + media_dir = core._ctx.media_dir / adapter.medium[ColumnType.COLUMN_TYPE_IMAGE] + fs.safe_mkdir(media_dir) + for filename, content in files: + fs.safe_write(media_dir / filename, content, mode="wb") + _write_records(core, records) + best = min(best, time.perf_counter() - start) + assert core._store is not None + core._store.close() + assert core is not None + return best, core + + +def _run_saves(root: Path, skip_store: bool, source_paths: List[Path]) -> Tuple[float, CorePython]: + best = float("inf") + core: Optional[CorePython] = None + for i in range(REPEATS): + core = _make_core(root, skip_store, f"save-{i}") + saves = _make_save_entries(source_paths) + start = time.perf_counter() + if not skip_store: + create_save_links(saves, core._ctx.files_dir) + _write_records(core, _to_save_envelopes(saves)) + best = min(best, time.perf_counter() - start) + assert core._store is not None + core._store.close() + assert core is not None + return best, core + + +# =========================================================================== +# 报告 +# =========================================================================== + + +def _report(rows: List[Tuple[str, str, float, float]], notes: Dict[str, object]) -> None: + print("\n" + "=" * 78) + print(" core.skip_store persistence benchmark") + print("=" * 78) + for k, v in notes.items(): + print(f" {k:16s}: {v}") + print("-" * 78) + print(f" {'category':<10}{'scale':<16}{'persist(s)':>14}{'skip(s)':>14}{'speedup':>12}") + print("-" * 78) + for name, scale, persist, skip in rows: + speedup = f"{persist / skip:,.1f}x" if skip > 0 else "inf" + print(f" {name:<10}{scale:<16}{persist:>14.4f}{skip:>14.6f}{speedup:>12}") + total_persist = sum(r[2] for r in rows) + total_skip = sum(r[3] for r in rows) + total_speedup = f"{total_persist / total_skip:,.1f}x" if total_skip > 0 else "inf" + print("-" * 78) + print(f" {'TOTAL':<10}{'':<16}{total_persist:>14.4f}{total_skip:>14.6f}{total_speedup:>12}") + print("=" * 78) + + +# =========================================================================== +# Benchmark +# =========================================================================== + + +def test_bench_skip_store_vs_persist(tmp_path): + """标量 + 媒体 + 文件保存:落盘 vs 完全跳过持久化的端到端对比。""" + source_paths = _make_source_files(tmp_path) + + p_scalar, p_scalar_core = _run_scalar(tmp_path, skip_store=False) + s_scalar, s_scalar_core = _run_scalar(tmp_path, skip_store=True) + p_media, p_media_core = _run_media(tmp_path, skip_store=False) + s_media, s_media_core = _run_media(tmp_path, skip_store=True) + p_save, p_save_core = _run_saves(tmp_path, skip_store=False, source_paths=source_paths) + s_save, s_save_core = _run_saves(tmp_path, skip_store=True, source_paths=source_paths) + + # ---- 数据完整性:persist 全部落盘,skip 全部不落盘 ---- + reader = DataStoreReader() + reader.open(str(p_scalar_core._ctx.run_file)) + scalar_read_count = sum(1 for _ in reader) + reader.close() + + media_dir = p_media_core._ctx.media_dir / adapter.medium[ColumnType.COLUMN_TYPE_IMAGE] + media_files = list(media_dir.glob("*.png")) + media_bytes = sum(f.stat().st_size for f in media_files) + + save_links = [p for p in p_save_core._ctx.files_dir.rglob("*") if not p.is_dir()] + skip_files_absent = all(not core._ctx.run_file.exists() for core in (s_scalar_core, s_media_core, s_save_core)) + + rows: List[Tuple[str, str, float, float]] = [ + ("scalars", f"{NUM_KEYS}x{NUM_STEPS}", p_scalar, s_scalar), + ("media", f"{MEDIA_COUNT}x{MEDIA_SIZE}B", p_media, s_media), + ("saves", f"{SAVE_COUNT} files", p_save, s_save), + ] + notes: Dict[str, object] = { + "records": f"{NUM_RECORDS} scalars + {MEDIA_COUNT} media + {SAVE_COUNT} saves", + "persist_scalar_throughput": f"{NUM_RECORDS / p_scalar:,.0f} rec/s", + "persist_media_bandwidth": f"{media_bytes / p_media / 1024 / 1024:,.1f} MiB/s", + "persist_run_file": f"{p_scalar_core._ctx.run_file.stat().st_size:,} bytes", + "persist_media_files": f"{len(media_files)} files / {media_bytes:,} bytes", + "persist_save_links": f"{len(save_links)} links", + "skip_local_files_absent": skip_files_absent, + } + _report(rows, notes) + + # 正确性断言 + assert scalar_read_count == NUM_RECORDS, f"scalar integrity failed: {scalar_read_count} != {NUM_RECORDS}" + assert len(media_files) == MEDIA_COUNT, f"media integrity failed: {len(media_files)} files" + assert media_bytes == MEDIA_COUNT * MEDIA_SIZE, f"media bytes mismatch: {media_bytes}" + assert len(save_links) == SAVE_COUNT, f"save links mismatch: {len(save_links)}" + assert skip_files_absent, "skip_store must not create a local run file" + for name, _, persist, skip in rows: + assert skip <= persist, f"skip_store path slower than persist for {name}: {skip:.4f}s > {persist:.4f}s" diff --git a/tests/unit/sdk/cmd/init/test_init_e2e.py b/tests/unit/sdk/cmd/init/test_init_e2e.py index 09c649719..c0ce44ebe 100644 --- a/tests/unit/sdk/cmd/init/test_init_e2e.py +++ b/tests/unit/sdk/cmd/init/test_init_e2e.py @@ -12,6 +12,7 @@ - TestInitSettingsPriority : 配置优先级(全局 < 自定义 < 传参) - TestInitResumeValidation : resume/id 校验逻辑 - TestInitOnlineMode : online 模式,依赖本文件内的 HTTP mock fixtures + - TestInitOnlineSkipStore : online + core.skip_store,本地不产生任何文件 - TestOnlineMultipleInit : online 模式多次 init/finish,验证 finish 后 client 重置与重新认证 - TestInitFactoryDispatch : 验证 factory 模式按模式分派组件类型 - TestRunSave : run.save() 各 policy / 各模式的端到端行为 @@ -34,6 +35,7 @@ from swanlab.sdk.internal.bus import MetricLogEvent, RunEmitter from swanlab.sdk.internal.core_python import client from swanlab.sdk.internal.pkg import console, fork +from swanlab.sdk.internal.probe_python import ProbePython from swanlab.sdk.internal.run import Run, get_run, has_run from swanlab.sdk.internal.run.components import BackgroundConsumer, NullConsumer, NullEmitter from swanlab.sdk.internal.run.components.config import config as global_config @@ -697,6 +699,193 @@ def test_init_online_sends_custom_experiment_name( assert run._ctx.config.settings.experiment.name == "test-save-exp" +# ============================================================ +# TestInitOnlineSkipStore +# ============================================================ + + +class TestInitOnlineSkipStore: + """core.skip_store=true:online run 全程不产生任何本地文件。 + + skip 下内部 save 以 payload 内联,由 sender 解析后上传 profile。 + """ + + @staticmethod + def _capture_probe_settings(monkeypatch, *, skip_store: bool): + """init 并捕获 probe 收到的 ProbeSettings。""" + captured = {} + original = ProbePython._start_when_enabled + + def _capture(self, start_request): + captured["probe_settings"] = start_request.probe_settings + return original(self, start_request) + + monkeypatch.setattr(ProbePython, "_start_when_enabled", _capture) + settings = Settings(core=Settings.Core(skip_store=True)) if skip_store else None + init(project=PROJECT, settings=settings) + return captured["probe_settings"] + + def test_skip_store_creates_no_local_dirs(self, logged_in_client, mock_online_init_apis): + """整个 log_dir(含 run/media/files/debug 子目录)都不落盘""" + run = init(project=PROJECT, settings=Settings(core=Settings.Core(skip_store=True))) + log_dir = run._ctx.config.settings.log_dir + + assert isinstance(run, Run) + assert run._ctx.config.settings.core.skip_store is True + + run.log({"loss": 0.5}) + run.finish() + + assert not log_dir.exists() + assert not run._ctx.run_dir.exists() + + def test_interactive_downgrade_disables_skip_store(self, monkeypatch, tmp_path): + """online 交互式降级到 offline 时,skip_store 被显式关闭而非抛裸 ValidationError。""" + monkeypatch.setenv("SWANLAB_CORE_SKIP_STORE", "true") + monkeypatch.setattr("swanlab.sdk.cmd.init.prompt_init_mode", lambda _: "offline") + + run = init(mode="online", log_dir=str(tmp_path / "swanlog")) + + assert run.mode == "offline" + assert run._ctx.config.settings.core.skip_store is False + assert run._ctx.config_file.exists() + run.finish() + + def test_skip_store_probe_has_no_run_dir( + self, + monkeypatch, + logged_in_client, + mock_project_get_api, + mock_experiment_create_api, + mock_experiment_stop_api, + mock_profile_api, + mock_heartbeat_api, + mock_metrics_api, + ): + """skip_store 下 probe settings 显式携带 skip_store 且不携带 run_dir。""" + probe_settings = self._capture_probe_settings(monkeypatch, skip_store=True) + + assert probe_settings.skip_store is True + assert probe_settings.HasField("run_dir") is False + + def test_default_probe_has_run_dir( + self, + monkeypatch, + logged_in_client, + mock_project_get_api, + mock_experiment_create_api, + mock_experiment_stop_api, + mock_profile_api, + mock_heartbeat_api, + mock_metrics_api, + ): + """默认模式回归:probe settings 携带 run_dir 且 skip_store 为 False。""" + probe_settings = self._capture_probe_settings(monkeypatch, skip_store=False) + + assert probe_settings.HasField("run_dir") is True + assert probe_settings.skip_store is False + + def test_skip_store_creates_no_config_file( + self, + logged_in_client, + mock_project_get_api, + mock_experiment_create_api, + mock_experiment_stop_api, + mock_profile_api, + mock_heartbeat_api, + mock_metrics_api, + rsps, + ): + """config 内容随 SaveRecord.payload 内联上传,绑定时的全量 flush 与后续写入都不落盘""" + # init 前写入,绑定时的全量 flush 已包含该键;连续写入受保留 num 去重影响,不作断言依据 + global_config["lr"] = 0.01 + run = init(project=PROJECT, settings=Settings(core=Settings.Core(skip_store=True))) + + assert not run._ctx.config_file.exists() + + run.config["epochs"] = 5 + + assert not run._ctx.config_file.exists() + + # finish 排空 Transport 后,config payload 已上云 + run.finish() + profile_bodies = [ + json.loads(cast(bytes, call.request.body)) for call in rsps.calls if call.request.url.endswith("/profile") + ] + assert any(body.get("config", {}).get("lr", {}).get("value") == 0.01 for body in profile_bodies) + + def test_skip_store_uploads_full_run_to_cloud( + self, + logged_in_client, + mock_online_skip_store_apis, + tmp_path, + rsps, + ): + """record 全部上云,本地零文件""" + import numpy as np + + global_config["lr"] = 0.01 + run = init(project=PROJECT, settings=Settings(core=Settings.Core(skip_store=True))) + checkpoint = tmp_path / "model.pt" + checkpoint.write_text("weights", encoding="utf-8") + + run.log({"loss": 0.5}) + run.log_image(key="img", data=np.zeros((10, 10, 3), dtype=np.uint8)) + for policy in ("now", "end", "live"): + run.save("model.pt", base_path=str(tmp_path), policy=policy) + + log_dir = run._ctx.config.settings.log_dir + run.finish() + + # 本地零文件 + assert not log_dir.exists() + + # 标量 / 日志 / 媒体元数据经 /house/metrics 上传,列经 /series 上传 + metric_bodies = [ + json.loads(cast(bytes, c.request.body)) for c in rsps.calls if c.request.url.endswith("/house/metrics") + ] + assert {body["type"] for body in metric_bodies} >= {"scalar", "media", "log"} + assert any(c.request.url.endswith("/series") for c in rsps.calls) + + # 媒体内容经预签名 URL 直传对象存储 + assert len([c for c in rsps.calls if c.request.url == MEDIA_UPLOAD_URL]) == 1 + presigned = [ + json.loads(cast(bytes, c.request.body)) + for c in rsps.calls + if c.request.url.endswith("/resources/presigned/put") + ] + assert any(body["paths"][0].startswith("media/image/") for body in presigned) + + # 内部 save(config / metadata / requirements)经 profile 上传 + profile_bodies = [ + json.loads(cast(bytes, c.request.body)) for c in rsps.calls if c.request.url.endswith("/profile") + ] + assert any("config" in body for body in profile_bodies) + assert any("metadata" in body for body in profile_bodies) + assert any("requirements" in body for body in profile_bodies) + + # custom save:prepare → 对象存储 PUT → complete + save_urls = [c.request.url for c in rsps.calls] + assert any("files/prepare" in url for url in save_urls) + assert sum(1 for url in save_urls if url in SKIP_STORE_SAVE_URLS) == 3 + assert any("files/complete" in url for url in save_urls) + + def test_default_creates_datastore( + self, + logged_in_client, + mock_project_get_api, + mock_experiment_create_api, + mock_experiment_stop_api, + mock_profile_api, + mock_heartbeat_api, + mock_metrics_api, + ): + run = init(mode="online", project=PROJECT) + + assert run._ctx.config.settings.core.skip_store is False + assert list(run._ctx.run_dir.glob("run-*.swanlab")) + + # ============================================================ # TestOnlineMultipleInit # [随临时方案删除] 验证 finish() 后 client 单例被销毁、下次 init() 重新认证获取新 sid, @@ -1161,6 +1350,79 @@ def mock_online_init_only( pass +# ============================================================ +# Media / skip_store 全量 run Mock Fixtures +# ============================================================ + +MEDIA_UPLOAD_URL = "https://storage.fake.swanlab.cn/media/0" +SKIP_STORE_SAVE_URLS = [f"https://storage.fake.swanlab.cn/save/{i}" for i in range(3)] + + +def make_media_presigned_resp(**overrides) -> dict: + """POST /api/resources/presigned/put 响应体""" + return {"urls": [MEDIA_UPLOAD_URL], **overrides} + + +@pytest.fixture +def mock_media_presigned_api(rsps): + """注册 POST /api/resources/presigned/put 端点(媒体上传凭据)""" + rsps.add( + responses_lib.POST, + f"{API_HOST}/api/resources/presigned/put", + json=make_media_presigned_resp(), + status=200, + ) + return rsps + + +@pytest.fixture +def mock_media_upload_api(rsps): + """注册 PUT 媒体预签名 URL 端点(媒体内容直传对象存储)""" + rsps.add(responses_lib.PUT, MEDIA_UPLOAD_URL, body="", status=200) + return rsps + + +@pytest.fixture +def mock_save_prepare_multi_api(rsps): + """注册 POST files/prepare 端点,返回与待上传文件数一致的预签名 URL""" + rsps.add( + responses_lib.POST, + f"{API_HOST}/api/experiment/{EXPERIMENT_CUID}/files/prepare", + json={"urls": list(SKIP_STORE_SAVE_URLS)}, + status=200, + ) + return rsps + + +@pytest.fixture +def mock_save_upload_multi_api(rsps): + """注册多个 save 预签名 URL 的 PUT 端点""" + for url in SKIP_STORE_SAVE_URLS: + rsps.add(responses_lib.PUT, url, body="", status=200) + return rsps + + +@pytest.fixture +def mock_online_skip_store_apis( + mock_online_settings, + mock_login_api, + mock_project_get_api, + mock_experiment_create_api, + mock_experiment_stop_api, + mock_profile_api, + mock_heartbeat_api, + mock_metrics_api, + mock_columns_api, + mock_media_presigned_api, + mock_media_upload_api, + mock_save_prepare_multi_api, + mock_save_complete_api, + mock_save_upload_multi_api, +): + """组合 fixture:skip_store 全量 run(指标 + 媒体 + 三类 save)所需端点。""" + pass + + # ============================================================ # TestRunSave # ============================================================ diff --git a/tests/unit/sdk/internal/core_python/store/test_datastore.py b/tests/unit/sdk/internal/core_python/store/test_datastore.py index 6ffbd3223..e4c6de98c 100644 --- a/tests/unit/sdk/internal/core_python/store/test_datastore.py +++ b/tests/unit/sdk/internal/core_python/store/test_datastore.py @@ -7,6 +7,7 @@ import struct from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -258,3 +259,63 @@ def test_scan_before_open_raises(self): r = DataStoreReader() with pytest.raises(AssertionError): r.scan() + + +# --------------------------------------------------------------------------- +# skip 设置(core.skip_store,仅 online 模式合法) +# --------------------------------------------------------------------------- + + +class TestSkipMode: + def test_open_does_not_create_file(self, tmp_path: Path): + p = tmp_path / "skip.swanlab" + w = DataStoreWriter(skip=True) + w.open(str(p)) + assert not p.exists() + assert w._fp is None + w.close() + + def test_skip_records_counts_without_persisting(self, tmp_path: Path): + p = tmp_path / "skip.swanlab" + w = DataStoreWriter(skip=True) + w.open(str(p)) + w.skip_records(3) + assert w._skipped_records == 3 + assert not p.exists() + w.close() + assert not p.exists() + + def test_write_rejected(self, tmp_path: Path): + """skip writer 不接受写入:record 只能经 skip_records() 登记。""" + w = DataStoreWriter(skip=True) + w.open(str(tmp_path / "skip.swanlab")) + with pytest.raises(AssertionError, match="skip writer"): + w.write(b"record_1") + + def test_open_twice_is_noop(self, tmp_path: Path): + """未启用 skip 时重复 open 抛 FileExistsError;启用后始终无副作用。""" + p = tmp_path / "skip.swanlab" + w = DataStoreWriter(skip=True) + w.open(str(p)) + w.open(str(p)) + assert not p.exists() + w.close() + + def test_close_logs_skipped_count(self, tmp_path: Path, monkeypatch): + debug = MagicMock() + monkeypatch.setattr("swanlab.sdk.internal.core_python.store.console.debug", debug) + w = DataStoreWriter(skip=True) + w.open(str(tmp_path / "skip.swanlab")) + w.skip_records(2) + w.close() + debug.assert_called_once_with("local store skipped, 2 records not persisted") + + def test_default_writer_still_persists(self, tmp_path: Path): + """默认 skip=False 行为不变:文件照常创建、写入、可读回。""" + p = tmp_path / "keep.swanlab" + w = DataStoreWriter() + w.open(str(p)) + w.write(b"persist me") + w.close() + assert p.exists() + assert read_all(p) == [b"persist me"] diff --git a/tests/unit/sdk/internal/core_python/test_core_python.py b/tests/unit/sdk/internal/core_python/test_core_python.py index 34b080f07..c6e95527e 100644 --- a/tests/unit/sdk/internal/core_python/test_core_python.py +++ b/tests/unit/sdk/internal/core_python/test_core_python.py @@ -9,6 +9,7 @@ - TestCorePythonPublish: 各模式 publish 行为 - TestCorePythonFinish : 各模式 deliver_run_finish 行为 - TestCorePythonGuard : 防御逻辑 + - TestCorePythonSkipStore: core.skip_store 主链路(无本地 datastore + live save 回归) """ from unittest.mock import MagicMock, patch @@ -23,6 +24,7 @@ ) from swanlab.proto.swanlab.operation.v1.operation_pb2 import CoreState from swanlab.proto.swanlab.run.v1.run_pb2 import FinishRecord, StartRecord +from swanlab.proto.swanlab.save.v1.save_pb2 import SavePolicy, SaveRecord from swanlab.proto.swanlab.settings.core.v1.core_pb2 import CoreSettings as CoreSettingsPb from swanlab.sdk.internal.core_python import CorePython from swanlab.sdk.internal.core_python.context import CoreConfig, CoreContext @@ -262,3 +264,112 @@ def test_fork_raises(self, tmp_path): with pytest.raises(RuntimeError, match="should not be called"): core.fork() + + +# ============================================================ +# TestCorePythonSkipStore +# ============================================================ + + +class TestCorePythonSkipStore: + """core.skip_store=true(仅 online 合法):本地不产生 datastore,记录仍进 Transport。""" + + def _start_online_core(self, tmp_path, monkeypatch, skip_store: bool) -> CorePython: + core = CorePython("online") + record = make_start_record() + mock_start = MagicMock(return_value=DeliverRunStartResponse(success=True, message="OK", run=record)) + + def _report_run_start_and_set_attrs(rec): + set_online_params(core) + core._metrics = MagicMock() + return mock_start(rec) + + monkeypatch.setattr(core, "_report_run_start", _report_run_start_and_set_attrs) + req = make_start_request(tmp_path, record) + req.core_settings.skip_store = skip_store + with patch("swanlab.sdk.internal.core_python.core.Heartbeat"): + resp = core.deliver_run_start(req) + assert resp.success is True + return core + + def test_skip_store_creates_no_datastore_file(self, tmp_path, monkeypatch): + core = self._start_online_core(tmp_path, monkeypatch, skip_store=True) + # from_proto 已解析该字段,skip 设置后 store 不产生文件 + assert core._ctx.config.skip_store is True + assert core._store is not None + assert core._store._skip is True + assert not core._ctx.run_file.exists() + assert core._transport is not None + + def test_default_creates_datastore_file(self, tmp_path, monkeypatch): + core = self._start_online_core(tmp_path, monkeypatch, skip_store=False) + assert core._ctx.config.skip_store is False + assert core._store is not None + assert core._store._skip is False + assert core._ctx.run_file.exists() + + def test_live_save_still_reaches_transport(self, tmp_path, monkeypatch): + """skip 设置时 _on_file_changed 不能早退,record 必须进 Transport。""" + core = self._start_online_core(tmp_path, monkeypatch, skip_store=True) + transport = MagicMock() + core._transport = transport + store = core._store + assert store is not None + skipped_before = store._skipped_records # start record 已经过 write() + + core._on_file_changed(SaveRecord()) + + transport.put.assert_called_once() + assert store._skipped_records == skipped_before + 1 + assert not core._ctx.run_file.exists() + + def test_live_save_after_finish_is_ignored(self, tmp_path, monkeypatch): + """finish 将 store 置 None 后,watcher 回调应被早退条件拦住,不触发 assert。""" + core = self._start_online_core(tmp_path, monkeypatch, skip_store=True) + monkeypatch.setattr("swanlab.sdk.internal.core_python.core.stop_experiment", lambda *a, **kw: None) + # _store_finish 在非 FINISHED 状态会生成 error log record 进 transport,需让上传成功以便排空 + monkeypatch.setattr("swanlab.sdk.internal.core_python.transport.sender.upload_log", lambda *a, **kw: None) + core.deliver_run_finish(DeliverRunFinishRequest(finish_record=FinishRecord())) + assert core._store is None + + core._on_file_changed(SaveRecord()) # 不抛 assert + + assert core._store is None + assert not core._ctx.run_file.exists() + + def test_store_records_skips_serialization(self, tmp_path, monkeypatch): + """skip 下 _store_records 不调用 SerializeToString,仅登记未持久化计数。""" + core = self._start_online_core(tmp_path, monkeypatch, skip_store=True) + store = core._store + assert store is not None + skipped_before = store._skipped_records + + record = MagicMock() + core._store_records([record, record, record]) + + record.SerializeToString.assert_not_called() + assert store._skipped_records == skipped_before + 3 + + def test_custom_save_skips_local_links(self, tmp_path, monkeypatch): + """skip 下不创建镜像软链接、不填 target_path,也不触碰 files 目录。""" + core = self._start_online_core(tmp_path, monkeypatch, skip_store=True) + transport = MagicMock() + core._transport = transport + core._watcher = MagicMock() + source = tmp_path / "checkpoints" / "model.pt" + source.parent.mkdir() + source.write_bytes(b"weights") + + save = SaveRecord( + name="checkpoints/model.pt", + source_path=str(source), + policy=SavePolicy.SAVE_POLICY_NOW, + ) + core.upsert_saves([save]) + + assert save.target_path == "" + assert source.read_bytes() == b"weights" # 源文件只读不动 + assert not (core._ctx.config.run_dir / "files").exists() + core._watcher.register_source_watches.assert_called_once_with([save]) + core._watcher.register_live_watches.assert_not_called() + transport.put.assert_called_once() diff --git a/tests/unit/sdk/internal/core_python/transport/test_sender.py b/tests/unit/sdk/internal/core_python/transport/test_sender.py index 74c42d4aa..058ee6976 100644 --- a/tests/unit/sdk/internal/core_python/transport/test_sender.py +++ b/tests/unit/sdk/internal/core_python/transport/test_sender.py @@ -1,7 +1,9 @@ from io import BytesIO from pathlib import Path +from typing import Optional, cast from unittest.mock import ANY, MagicMock, patch +import pytest from google.protobuf.timestamp_pb2 import Timestamp from swanlab.exceptions import ApiError @@ -48,6 +50,7 @@ def _make_sender( save_batch: int = 100, save_split: int = 100 * 1024 * 1024, save_part: int = 32 * 1024 * 1024, + skip_store: bool = False, ) -> HttpRecordSender: ctx = CoreContext( config=CoreConfig( @@ -60,6 +63,7 @@ def _make_sender( save_size=50 * 1024 * 1024 * 1024, save_part=save_part, save_batch=save_batch, + skip_store=skip_store, ) ) ctx.set_online_params( @@ -80,16 +84,23 @@ def _make_save_record( return Record(save=SaveRecord(name=name, source_path=str(source), target_path=str(source), type=save_type)) -def _make_media_record(filename: str, media_type=ColumnType.COLUMN_TYPE_IMAGE) -> Record: +def _make_media_record( + filename: str, media_type=ColumnType.COLUMN_TYPE_IMAGE, payload: Optional[bytes] = None +) -> Record: timestamp = Timestamp() timestamp.GetCurrentTime() + # payload=None 表示未提供 payload(默认模式,从本地路径读取); + # payload=b"" 表示合法的空文件(skip_store),两者必须可区分。 + item = MediaItem(filename=filename) + if payload is not None: + item.payload = payload return Record( media=MediaRecord( key="examples/image", step=1, type=media_type, timestamp=timestamp, - value=MediaValue(items=[MediaItem(filename=filename)]), + value=MediaValue(items=[item]), ) ) @@ -360,6 +371,64 @@ def _fake_upload_resource(_, __, *, paths, buffers, content_types=None, tracker= assert list(snapshots[1].files) == [] +def test_upload_media_uses_payload_without_touching_disk(tmp_path: Path): + """payload 走内存上传、不读本地文件;缺 payload 时告警丢弃。""" + sender = _make_sender(tmp_path, skip_store=True) + captured = {} + + def _fake_upload_resource(_, __, *, paths, buffers, content_types=None, tracker=None): + captured["paths"] = paths + captured["content_types"] = content_types + captured["content"] = buffers[0].read() + + with ( + patch("swanlab.sdk.internal.core_python.transport.sender.client.session.create", return_value=MagicMock()), + patch( + "swanlab.sdk.internal.core_python.transport.sender.upload_resource", + side_effect=_fake_upload_resource, + ), + patch("swanlab.sdk.internal.core_python.transport.sender.upload_media") as mock_upload_media, + patch("swanlab.sdk.internal.core_python.transport.sender.console.warning") as mock_warning, + ): + sender.upload_media([_make_media_record("ok.png", payload=b"ok"), _make_media_record("missing.png")]) + + assert captured["paths"] == ["media/image/ok.png"] + assert captured["content_types"] == ["image/png"] + assert captured["content"] == b"ok" + mock_upload_media.assert_called_once() + assert "Media payload missing" in mock_warning.call_args.args[0] + # 不得访问/创建本地 media 目录 + assert not (tmp_path / "media").exists() + + +def test_upload_media_uploads_empty_payload_as_zero_byte_file(tmp_path: Path): + """skip_store 下合法的空文件(payload 存在但为 b"")必须上传,不能被当作缺失丢弃。""" + sender = _make_sender(tmp_path, skip_store=True) + captured = {} + + def _fake_upload_resource(_, __, *, paths, buffers, content_types=None, tracker=None): + captured["paths"] = paths + captured["content_types"] = content_types + captured["content"] = buffers[0].read() + + with ( + patch("swanlab.sdk.internal.core_python.transport.sender.client.session.create", return_value=MagicMock()), + patch( + "swanlab.sdk.internal.core_python.transport.sender.upload_resource", + side_effect=_fake_upload_resource, + ), + patch("swanlab.sdk.internal.core_python.transport.sender.upload_media") as mock_upload_media, + patch("swanlab.sdk.internal.core_python.transport.sender.console.warning") as mock_warning, + ): + sender.upload_media([_make_media_record("empty.txt", ColumnType.COLUMN_TYPE_TEXT, payload=b"")]) + + assert captured["paths"] == ["media/text/empty.txt"] + assert captured["content_types"] == ["text/plain"] + assert captured["content"] == b"" + mock_upload_media.assert_called_once() + mock_warning.assert_not_called() + + def test_upload_advances_records_only_on_success(tmp_path: Path): """成功上传才递进 uploaded,进度条仅保留上传成功的进度。""" tracker = UploadTracker() @@ -427,7 +496,7 @@ def test_resolve_save_source_prefers_primary_when_readable(tmp_path: Path): sender = _make_sender(tmp_path) with patch("swanlab.sdk.internal.core_python.transport.sender.console.debug") as mock_debug: - result = sender._resolve_save_source(rec.save) + result = sender.resolve_save_source(rec.save) assert result == source mock_debug.assert_not_called() @@ -444,7 +513,7 @@ def test_resolve_save_source_falls_back_to_files_dir_for_custom(tmp_path: Path): fallback.write_text("recovered", encoding="utf-8") with patch("swanlab.sdk.internal.core_python.transport.sender.console.debug") as mock_debug: - result = sender._resolve_save_source(rec.save) + result = sender.resolve_save_source(rec.save) assert result == fallback mock_debug.assert_called_once() @@ -458,7 +527,7 @@ def test_resolve_save_source_falls_back_for_internal_metadata(tmp_path: Path): fallback.parent.mkdir(parents=True) fallback.write_text("{}", encoding="utf-8") - result = sender._resolve_save_source(rec.save) + result = sender.resolve_save_source(rec.save) assert result == fallback @@ -473,7 +542,7 @@ def test_resolve_save_source_uses_basename_not_name_for_config(tmp_path: Path): # files/config(即 save.name)不应被命中 assert not (tmp_path / "files" / "config").exists() - result = sender._resolve_save_source(rec.save) + result = sender.resolve_save_source(rec.save) assert result == fallback @@ -483,7 +552,7 @@ def test_resolve_save_source_returns_none_when_both_unreadable(tmp_path: Path): sender = _make_sender(tmp_path) # 既无 primary 也无 files 镜像 - assert sender._resolve_save_source(rec.save) is None + assert sender.resolve_save_source(rec.save) is None def test_upload_save_recovers_internal_metadata_from_run_dir(tmp_path: Path): @@ -560,7 +629,7 @@ def test_resolve_save_source_handles_windows_separators_on_posix(tmp_path: Path) config_fallback.parent.mkdir(parents=True) config_fallback.write_text("key: value", encoding="utf-8") internal_rec = _make_internal_save_record(r"C:\host\config.yaml", SaveType.SAVE_TYPE_CONFIG, name="config") - assert sender._resolve_save_source(internal_rec.save) == config_fallback + assert sender.resolve_save_source(internal_rec.save) == config_fallback # ── 用户保存(CUSTOM):层级还原 ── custom_fallback = tmp_path / "files" / "checkpoints" / "model.pt" @@ -574,7 +643,160 @@ def test_resolve_save_source_handles_windows_separators_on_posix(tmp_path: Path) type=SaveType.SAVE_TYPE_CUSTOM, ) ) - assert sender._resolve_save_source(custom_rec.save) == custom_fallback + assert sender.resolve_save_source(custom_rec.save) == custom_fallback + + +# ============================================================ +# skip_store:内部 save payload 双源 +# ============================================================ + + +_INTERNAL_PAYLOAD_CASES = [ + pytest.param( + SaveType.SAVE_TYPE_METADATA, + b'{"hostname": "gpu-01"}', + "upload_metadata", + {"hostname": "gpu-01"}, + id="metadata", + ), + pytest.param( + SaveType.SAVE_TYPE_REQUIREMENTS, + b"numpy==1.0", + "upload_requirements", + "numpy==1.0", + id="requirements", + ), + pytest.param( + SaveType.SAVE_TYPE_CONFIG, + b"lr:\n value: 0.01\n", + "upload_config", + {"lr": {"value": 0.01}}, + id="config", + ), +] + + +@pytest.mark.parametrize("save_type, payload, api_func, expected_content", _INTERNAL_PAYLOAD_CASES) +def test_upload_save_uses_internal_payload_without_disk(tmp_path, save_type, payload, api_func, expected_content): + """内部 save payload 非空:直接解析上传,不走 files 回退。""" + sender = _make_sender(tmp_path) + record = Record(save=SaveRecord(name="internal", type=save_type, payload=payload)) + + with ( + patch(f"swanlab.sdk.internal.core_python.transport.sender.{api_func}") as mock_api, + patch.object(sender, "resolve_save_source") as mock_resolve, + ): + sender.upload_save([record]) + + mock_resolve.assert_not_called() + mock_api.assert_called_once() + assert mock_api.call_args.kwargs["content"] == expected_content + + +def test_upload_save_rejects_invalid_payload_usage(tmp_path: Path): + """CUSTOM 带 payload、skip_store 下内部 save 缺 payload,均告警跳过、不走上传。""" + source = tmp_path / "model.pt" + source.write_bytes(b"weights") + sender = _make_sender(tmp_path, skip_store=True) + custom_with_payload = Record( + save=SaveRecord( + name="model.pt", + source_path=str(source), + type=SaveType.SAVE_TYPE_CUSTOM, + payload=b"forbidden", + ) + ) + internal_without_payload = _make_internal_save_record("", SaveType.SAVE_TYPE_METADATA) + + with ( + patch("swanlab.sdk.internal.core_python.transport.sender.prepare_save_files") as mock_prepare, + patch.object(sender, "resolve_save_source") as mock_resolve, + patch("swanlab.sdk.internal.core_python.transport.sender.upload_metadata") as mock_upload_meta, + patch("swanlab.sdk.internal.core_python.transport.sender.console.warning") as mock_warning, + ): + sender.upload_save([custom_with_payload, internal_without_payload]) + + mock_prepare.assert_not_called() + mock_resolve.assert_not_called() + mock_upload_meta.assert_not_called() + messages = [call.args[0] for call in mock_warning.call_args_list] + assert any("CUSTOM save must not carry payload" in message for message in messages) + assert any("Internal save payload missing" in message for message in messages) + + +def test_upload_save_skips_unparseable_payload(tmp_path: Path): + """payload 无法解析属确定性脏数据:告警跳过,不进入上传、不触发 Transport 无上限重试。""" + sender = _make_sender(tmp_path) + record = Record(save=SaveRecord(name="metadata", type=SaveType.SAVE_TYPE_METADATA, payload=b"{not-json")) + + with ( + patch("swanlab.sdk.internal.core_python.transport.sender.upload_metadata") as mock_upload_meta, + patch("swanlab.sdk.internal.core_python.transport.sender.console.warning") as mock_warning, + ): + sender.upload("save", [record]) # 不得抛异常 + + mock_upload_meta.assert_not_called() + assert "Failed to parse internal save payload" in mock_warning.call_args.args[0] + + +def test_upload_save_payload_upload_error_is_not_swallowed(tmp_path: Path): + """payload 解析成功但上传失败(5xx)时上抛,交 Transport 重试,不静默吞掉。""" + sender = _make_sender(tmp_path) + record = Record( + save=SaveRecord(name="metadata", type=SaveType.SAVE_TYPE_METADATA, payload=b'{"hostname": "gpu-01"}') + ) + error = ApiError( + _FakeApiErrorResponse(502), + method="PUT", + trace_id="trace-id", + code="bad-gateway", + message="server error", + ) + + with patch( + "swanlab.sdk.internal.core_python.transport.sender.upload_metadata", + side_effect=error, + ): + with pytest.raises(ApiError): + sender.upload("save", [record]) + + +def test_upload_save_disk_channel_upload_error_is_not_swallowed(tmp_path: Path): + """默认模式磁盘回读:上传失败(5xx)同样上抛交 Transport 重试,不被静默吞掉。""" + sender = _make_sender(tmp_path) + metadata_file = tmp_path / "files" / "swanlab-metadata.json" + metadata_file.parent.mkdir(parents=True) + metadata_file.write_text('{"hostname": "gpu-01"}', encoding="utf-8") + record = _make_internal_save_record("/nonexistent/host/swanlab-metadata.json", SaveType.SAVE_TYPE_METADATA) + error = ApiError( + _FakeApiErrorResponse(502), + method="PUT", + trace_id="trace-id", + code="bad-gateway", + message="server error", + ) + + with patch( + "swanlab.sdk.internal.core_python.transport.sender.upload_metadata", + side_effect=error, + ): + with pytest.raises(ApiError): + sender.upload("save", [record]) + + +def test_upload_save_warns_once_for_unknown_internal_type(tmp_path: Path): + """未知内部 save 类型:单次告警跳过,不读盘、不上传。""" + sender = _make_sender(tmp_path) + record = Record(save=SaveRecord(name="unknown", type=cast(SaveType, 99), payload=b"opaque")) + + with ( + patch.object(sender, "resolve_save_source") as mock_resolve, + patch("swanlab.sdk.internal.core_python.transport.sender.console.warning") as mock_warning, + ): + sender.upload_save([record]) + + mock_resolve.assert_not_called() + assert "Unknown internal save type" in mock_warning.call_args.args[0] # ============================================================ diff --git a/tests/unit/sdk/internal/core_python/watcher/test_watcher.py b/tests/unit/sdk/internal/core_python/watcher/test_watcher.py index 59b60a426..be183bc08 100644 --- a/tests/unit/sdk/internal/core_python/watcher/test_watcher.py +++ b/tests/unit/sdk/internal/core_python/watcher/test_watcher.py @@ -1,13 +1,39 @@ +import os +import time from pathlib import Path +from typing import Callable, List from unittest.mock import MagicMock +from watchdog.events import FileMovedEvent + +from swanlab.proto.swanlab.save.v1.save_pb2 import SavePolicy, SaveRecord from swanlab.sdk.internal.core_python.watcher import FileWatcher +from swanlab.sdk.internal.core_python.watcher.helper import _Handler def _make_watcher() -> FileWatcher: return FileWatcher(on_change=MagicMock(), debounce_delay=0.1) +def _make_save(name: str, source: Path, policy: SavePolicy = SavePolicy.SAVE_POLICY_LIVE) -> SaveRecord: + return SaveRecord(name=name, source_path=str(source), policy=policy) + + +def _wait_until(predicate: Callable[[], bool], timeout: float = 15.0) -> bool: + """轮询等待条件成立,用于真实 observer 测试(事件到达时间因平台而异)。""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.05) + return False + + +# Windows 的 os.stat 读 NTFS 目录项缓存,句柄关闭后约 1s 内文件签名仍然是旧值; +# 若 debounce 定时在该窗口内触发,签名比较会读到旧签名而误判"未变化"。 +REAL_OBSERVER_DEBOUNCE = 1.5 + + # ── watch idempotency ── @@ -26,7 +52,7 @@ def test_watch_skips_already_registered_file(tmp_path: Path): # 仍然只有一条记录,且签名是第一次注册时的 assert len(watcher._registered) == 1 abs_path = str((tmp_path / "model.pt").resolve()) - assert watcher._registered[abs_path].signature is not None + assert watcher._registered[abs_path][0].signature is not None def test_watch_registers_different_files(tmp_path: Path): @@ -67,3 +93,143 @@ def test_observer_starts_only_once(tmp_path: Path): watcher._observer = observer_mock watcher.watch(str(tmp_path), ["b.pt"]) observer_mock.schedule.assert_not_called() + + +# ── direct-source(skip_store,无本地镜像)── + + +def test_watch_sources_and_notifies_each_name(tmp_path: Path): + """同一源文件保存为多个 name:一对多注册,变化时每个 name 各回调一次。""" + calls = [] + watcher = FileWatcher(on_change=calls.append, debounce_delay=0.1) + source = tmp_path / "model.pt" + source.write_bytes(b"v1") + + watcher.watch_sources([_make_save("model.pt", source), _make_save("weights/model.pt", source)]) + + key = str(source.resolve()) + assert list(watcher._registered) == [key] + assert [e.name for e in watcher._registered[key]] == ["model.pt", "weights/model.pt"] + assert watcher._registered[key][0].source_path == key + assert watcher._registered[key][0].target_path == "" + # 重复注册幂等 + watcher.watch_sources([_make_save("model.pt", source)]) + assert len(watcher._registered[key]) == 2 + + for entry in watcher._registered[key]: + entry.signature = "stale" + source.write_bytes(b"v2") + watcher._process_change(key) + + assert sorted(r.name for r in calls) == ["model.pt", "weights/model.pt"] + assert all(r.source_path == key and r.target_path == "" for r in calls) + + +def test_watch_sources_ignores_other_files_in_same_dir(tmp_path: Path): + on_change = MagicMock() + watcher = FileWatcher(on_change=on_change, debounce_delay=0.1) + source = tmp_path / "model.pt" + other = tmp_path / "other.pt" + source.write_bytes(b"v1") + other.write_bytes(b"v1") + + watcher.watch_sources([_make_save("model.pt", source)]) + + other_abs = str(other.resolve()) + watcher._schedule_debounce(other_abs) + assert watcher._timers == {} + watcher._process_change(other_abs) + on_change.assert_not_called() + + +def test_watch_sources_missing_source_keeps_registration(tmp_path: Path): + """文件暂时缺失时保留注册:不回调,且删除后重建仍能触发回调。""" + on_change = MagicMock() + watcher = FileWatcher(on_change=on_change, debounce_delay=0.1) + source = tmp_path / "model.pt" + source.write_bytes(b"v1") + + watcher.watch_sources([_make_save("model.pt", source)]) + key = str(source.resolve()) + + # debounce 窗口内文件被删除:不回调,也不移除注册 + source.unlink() + watcher._process_change(key) + assert key in watcher._registered + on_change.assert_not_called() + + # 删除后重建:签名变化触发回调 + source.write_bytes(b"v2") + watcher._process_change(key) + on_change.assert_called_once() + assert on_change.call_args[0][0].source_path == key + + +def test_on_moved_matches_registered_dest_only(tmp_path: Path): + """on_moved 按 dest_path 匹配注册表:tmp 源路径与未注册目标都不触发。""" + watcher = _make_watcher() + source = tmp_path / "model.pt" + source.write_bytes(b"v1") + watcher.watch_sources([_make_save("model.pt", source)]) + key = str(source.resolve()) + + handler = _Handler(watcher) + handler.on_moved(FileMovedEvent(src_path=str(tmp_path / "model.pt.tmp"), dest_path=key)) + assert key in watcher._timers + + # 同目录其他文件的原子替换:dest 未注册,不触发 + watcher._timers.clear() + handler.on_moved(FileMovedEvent(src_path=str(tmp_path / "a.tmp"), dest_path=str(tmp_path / "other.pt"))) + assert watcher._timers == {} + + +# ── 真实 observer(跨平台事件语义)── + + +def test_watch_sources_real_observer_atomic_replace(tmp_path: Path): + """真实 observer 下 tmp + os.replace 的原子替换必须触发回调。 + + Linux/Windows 将原子替换上报为 moved 事件而非 modified,依赖 on_moved; + macOS/FSEvents 可能附带 modified 事件,因此断言只针对最终回调。 + """ + calls: List[SaveRecord] = [] + watcher = FileWatcher(on_change=calls.append, debounce_delay=REAL_OBSERVER_DEBOUNCE) + source = tmp_path / "model.pt" + source.write_bytes(b"v1") + watcher.watch_sources([_make_save("model.pt", source)]) + + try: + # in-place 写作为 warm-up,确认 observer 已开始接收事件 + source.write_bytes(b"v2-warm-up") + assert _wait_until(lambda: len(calls) >= 1), "observer warm-up write not detected" + + tmp = tmp_path / "model.pt.tmp" + tmp.write_bytes(b"v3-atomic-replace") + os.replace(tmp, source) + assert _wait_until(lambda: len(calls) >= 2), f"atomic replace not detected, calls: {len(calls)}" + assert calls[-1].source_path == str(source.resolve()) + finally: + watcher.stop() + + +def test_watch_sources_real_observer_delete_and_recreate(tmp_path: Path): + """删除后重建的文件仍在监听范围内:debounce 窗口内删除(定时器在文件缺失时触发)不得移除注册。""" + calls: List[SaveRecord] = [] + watcher = FileWatcher(on_change=calls.append, debounce_delay=REAL_OBSERVER_DEBOUNCE) + source = tmp_path / "model.pt" + source.write_bytes(b"v1") + watcher.watch_sources([_make_save("model.pt", source)]) + + try: + source.write_bytes(b"v2-warm-up") + assert _wait_until(lambda: len(calls) >= 1), "observer warm-up write not detected" + + # 写入后立刻删除:pending 的 debounce 定时器将在文件缺失时触发, + # 注册不得因此被移除,重建后仍要能触发回调 + source.write_bytes(b"v3-doomed") + source.unlink() + time.sleep(REAL_OBSERVER_DEBOUNCE + 0.3) + source.write_bytes(b"v4-recreated") + assert _wait_until(lambda: len(calls) >= 2), f"recreated file not detected, calls: {len(calls)}" + finally: + watcher.stop() diff --git a/tests/unit/sdk/internal/run/components/config/test_config.py b/tests/unit/sdk/internal/run/components/config/test_config.py index 0fe48da3d..8ffe31915 100644 --- a/tests/unit/sdk/internal/run/components/config/test_config.py +++ b/tests/unit/sdk/internal/run/components/config/test_config.py @@ -142,6 +142,65 @@ def test_module_reset_function(self, tmp_path): assert run_cfg["run_key"] == 1 # run config 不受影响(已创建的实例独立存在) +# ============================================================ +# skip_store:不落盘,内容随 ConfigEvent 内联 +# ============================================================ + + +class TestSkipStore: + """online + core.skip_store:config.yaml 不落盘,内容随 ConfigEvent 内联上传云端""" + + def test_bind_never_writes_file(self, tmp_path): + """绑定与后续写入均不落盘,也不重建父目录(swanlog 目录树必须保持不存在)""" + cfg = Config() + config_file = tmp_path / "files" / "config.yaml" + + cfg["lr"] = 0.01 + cfg._bindctx(config_file, make_emit(), skip_store=True) + cfg["epochs"] = 10 + + assert not config_file.exists() + assert not config_file.parent.exists() + + def test_event_carries_canonical_content(self, tmp_path): + """事件 content 为与 config.yaml 同构的 {key: {value, desc, sort}} 结构""" + cfg = Config() + emit = make_emit() + cfg["lr"] = 0.01 + cfg._bindctx(tmp_path / "config.yaml", emit, skip_store=True) + emit.reset_mock() + + cfg["epochs"] = 10 + + emit.assert_called_once() + event: ConfigEvent = emit.call_args[0][0] + assert event.content == { + "lr": {"value": 0.01, "desc": "", "sort": 0}, + "epochs": {"value": 10, "desc": "", "sort": 1}, + } + + def test_default_mode_content_matches_file(self, tmp_path): + """默认模式同样携带 content,且与落盘 YAML 还原结果一致""" + cfg, emit, config_file = bound_config(tmp_path) + emit.reset_mock() + + cfg["lr"] = 0.01 + + event: ConfigEvent = emit.call_args[0][0] + assert event.content == yaml.safe_load(config_file.read_text()) + + def test_reset_restores_file_writing(self, tmp_path): + """reset 后重新绑定回到默认落盘行为""" + cfg = Config() + cfg._bindctx(tmp_path / "a" / "config.yaml", make_emit(), skip_store=True) + cfg._reset() + + config_file = tmp_path / "config.yaml" + cfg._bindctx(config_file, make_emit()) + + assert config_file.exists() + + # ============================================================ # 生命周期:绑定后写操作 # ============================================================ diff --git a/tests/unit/sdk/internal/run/components/config/test_writer.py b/tests/unit/sdk/internal/run/components/config/test_writer.py index 454e6e6fa..7525d0131 100644 --- a/tests/unit/sdk/internal/run/components/config/test_writer.py +++ b/tests/unit/sdk/internal/run/components/config/test_writer.py @@ -2,82 +2,77 @@ @author: cunyue @file: test_writer.py @time: 2026/3/14 -@description: 测试 config/_writer.py:write_config 序列化与落盘行为 +@description: 测试 config/_writer.py:format/dump 序列化与 write_config 落盘行为 """ import yaml -from swanlab.sdk.internal.run.components.config.writer import write_config +from swanlab.sdk.internal.run.components.config.writer import dump_config, format_config, write_config -class TestWriteConfig: - def test_creates_yaml_file(self, tmp_path): - path = tmp_path / "config.yaml" - write_config(path, {"lr": 0.01}, {"lr": 0}) - - assert path.exists() - - def test_value_desc_sort_structure(self, tmp_path): - """每个 key 应序列化为 {value, desc, sort} 结构""" - path = tmp_path / "config.yaml" - write_config(path, {"lr": 0.01}, {"lr": 3}) +class TestFormatConfig: + def test_value_desc_sort_structure(self): + """每个 key 应归一化为 {value, desc, sort} 结构""" + content = format_config({"lr": 0.01}, {"lr": 3}) - data = yaml.safe_load(path.read_text()) - assert data["lr"]["value"] == 0.01 - assert data["lr"]["desc"] == "" - assert data["lr"]["sort"] == 3 + assert content == {"lr": {"value": 0.01, "desc": "", "sort": 3}} - def test_sort_from_sort_map(self, tmp_path): + def test_sort_from_sort_map(self): """sort 字段应取自 sort_map""" - path = tmp_path / "config.yaml" - write_config(path, {"a": 1, "b": 2}, {"a": 0, "b": 1}) + content = format_config({"a": 1, "b": 2}, {"a": 0, "b": 1}) - data = yaml.safe_load(path.read_text()) - assert data["a"]["sort"] == 0 - assert data["b"]["sort"] == 1 + assert content["a"]["sort"] == 0 + assert content["b"]["sort"] == 1 - def test_missing_sort_defaults_to_zero(self, tmp_path): + def test_missing_sort_defaults_to_zero(self): """sort_map 未包含的 key 默认 sort=0""" - path = tmp_path / "config.yaml" - write_config(path, {"x": 99}, {}) + content = format_config({"x": 99}, {}) + + assert content["x"]["sort"] == 0 - data = yaml.safe_load(path.read_text()) - assert data["x"]["sort"] == 0 - def test_unicode_allowed(self, tmp_path): +class TestDumpConfig: + def test_unicode_allowed(self): """中文字符应原样保留,不被转义""" + text = dump_config(format_config({"名称": "实验一"}, {"名称": 0})) + + assert "名称" in text + assert "实验一" in text + + def test_round_trip(self): + """dump 结果可被 yaml.safe_load 完整还原""" + content = format_config({"lr": 0.01, "epochs": 10, "name": "exp"}, {"lr": 0, "epochs": 1, "name": 2}) + + assert yaml.safe_load(dump_config(content)) == content + + def test_empty_config(self): + """空 config 应序列化为空 YAML 对象(不报错)""" + text = dump_config(format_config({}, {})) + + assert yaml.safe_load(text) in (None, {}) + + +class TestWriteConfig: + def test_creates_yaml_file(self, tmp_path): path = tmp_path / "config.yaml" - write_config(path, {"名称": "实验一"}, {"名称": 0}) + write_config(path, dump_config(format_config({"lr": 0.01}, {"lr": 0}))) - raw = path.read_text(encoding="utf-8") - assert "名称" in raw - assert "实验一" in raw + assert path.exists() - def test_empty_config(self, tmp_path): - """空 config 应写出空 YAML 对象(不报错)""" + def test_writes_given_serialized_content(self, tmp_path): + """写入的应是调用方传入的已序列化文本,不再自行格式化""" path = tmp_path / "config.yaml" - write_config(path, {}, {}) + write_config(path, "lr:\n value: 0.01\n desc: ''\n sort: 0\n") data = yaml.safe_load(path.read_text()) - assert data is None or data == {} + assert data == {"lr": {"value": 0.01, "desc": "", "sort": 0}} def test_overwrites_existing_file(self, tmp_path): """重复调用应全量覆盖,不追加旧内容""" path = tmp_path / "config.yaml" - write_config(path, {"lr": 0.01}, {"lr": 0}) - write_config(path, {"epochs": 10}, {"epochs": 0}) + write_config(path, dump_config(format_config({"lr": 0.01}, {"lr": 0}))) + write_config(path, dump_config(format_config({"epochs": 10}, {"epochs": 0}))) data = yaml.safe_load(path.read_text()) assert "lr" not in data assert data["epochs"]["value"] == 10 - - def test_multiple_keys(self, tmp_path): - path = tmp_path / "config.yaml" - cfg = {"lr": 0.01, "epochs": 10, "name": "exp"} - sort_map = {"lr": 0, "epochs": 1, "name": 2} - write_config(path, cfg, sort_map) - - data = yaml.safe_load(path.read_text()) - assert set(data.keys()) == {"lr", "epochs", "name"} - for k, s in sort_map.items(): - assert data[k]["sort"] == s diff --git a/tests/unit/sdk/internal/run/components/consumer/builder/test_builder.py b/tests/unit/sdk/internal/run/components/consumer/builder/test_builder.py index 10e3a12c8..d23cfab16 100644 --- a/tests/unit/sdk/internal/run/components/consumer/builder/test_builder.py +++ b/tests/unit/sdk/internal/run/components/consumer/builder/test_builder.py @@ -5,9 +5,16 @@ @description: RecordBuilder 单元测试 """ +from pathlib import Path +from types import SimpleNamespace + import pytest +import yaml +from google.protobuf.timestamp_pb2 import Timestamp from swanlab.proto.swanlab.metric.data.v1.data_pb2 import MediaItem, MediaRecord +from swanlab.proto.swanlab.save.v1.save_pb2 import SaveType +from swanlab.sdk.internal.bus.events import ConfigEvent from swanlab.sdk.internal.run.components.consumer.builder import _NON_SCALAR_TYPES, RecordBuilder, is_scalar_value from swanlab.sdk.internal.run.transforms import Text @@ -103,6 +110,57 @@ def test_exact_boundary_length_not_truncated(self, builder): assert result is record +class TestResolveMediaDir: + """_resolve_media_dir 依据 core.skip_store 决定媒体是否落盘""" + + def test_skip_store_returns_none_and_never_mkdir(self, tmp_path): + """skip_store 下返回 None 且不创建 media 目录""" + media_dir = tmp_path / "media" + settings = SimpleNamespace(core=SimpleNamespace(skip_store=True)) + ctx = SimpleNamespace(config=SimpleNamespace(settings=settings), media_dir=media_dir) + builder = RecordBuilder(ctx) # type: ignore[arg-type] + assert builder._resolve_media_dir(Text.column_type()) is None + assert not media_dir.exists() + + +class TestBuildConfig: + """build_config 依据 core.skip_store 决定内容内联进 payload 还是回读磁盘""" + + CONFIG_PATH = Path("/tmp/run/files/config.yaml") + + @staticmethod + def _builder(skip_store: bool) -> RecordBuilder: + settings = SimpleNamespace(core=SimpleNamespace(skip_store=skip_store)) + ctx = SimpleNamespace(config=SimpleNamespace(settings=settings)) + return RecordBuilder(ctx) # type: ignore[arg-type] + + def _event(self) -> ConfigEvent: + ts = Timestamp() + ts.GetCurrentTime() + return ConfigEvent( + path=self.CONFIG_PATH, + timestamp=ts, + content={"lr": {"value": 0.01, "desc": "", "sort": 0}}, + ) + + def test_skip_store_inlines_payload(self): + """skip_store 下内容按落盘同款 YAML 编码填入 payload,source_path 留空""" + event = self._event() + record = self._builder(True).build_config(event) + + assert record.name == "config" + assert record.type == SaveType.SAVE_TYPE_CONFIG + assert record.source_path == "" + assert yaml.safe_load(record.payload) == event.content + + def test_default_reads_from_disk(self): + """默认模式 payload 恒空,由 Core 按 source_path 回读 config.yaml""" + record = self._builder(False).build_config(self._event()) + + assert record.source_path == self.CONFIG_PATH.absolute().as_posix() + assert record.payload == b"" + + class TestIsScalarValue: """is_scalar_value 必须与 build_scalar_or_media 的分派结果一致。 diff --git a/tests/unit/sdk/internal/run/components/consumer/test_consumer_step_sync.py b/tests/unit/sdk/internal/run/components/consumer/test_consumer_step_sync.py index 8137b06db..42c330f44 100644 --- a/tests/unit/sdk/internal/run/components/consumer/test_consumer_step_sync.py +++ b/tests/unit/sdk/internal/run/components/consumer/test_consumer_step_sync.py @@ -22,7 +22,7 @@ def _make_consumer(tmp_path: Path) -> BackgroundConsumer: core=MagicMock(), callbacker=MagicMock(), media_dir=tmp_path, - config=SimpleNamespace(settings=SimpleNamespace(core=SimpleNamespace(section_rule=0))), + config=SimpleNamespace(settings=SimpleNamespace(core=SimpleNamespace(section_rule=0, skip_store=False))), ) run_ctx = cast(RunContext, cast(object, ctx)) return BackgroundConsumer(run_ctx, queue.Queue()) diff --git a/tests/unit/sdk/internal/run/transforms/test_media_transform.py b/tests/unit/sdk/internal/run/transforms/test_media_transform.py index f6b27a0fb..94ff26fee 100644 --- a/tests/unit/sdk/internal/run/transforms/test_media_transform.py +++ b/tests/unit/sdk/internal/run/transforms/test_media_transform.py @@ -104,3 +104,14 @@ def test_same_content_same_filename(self, factory, tmp_path: Path): item1 = factory().transform(step=1, path=tmp_path) item2 = factory().transform(step=1, path=tmp_path) assert item1.filename == item2.filename + + def test_reusable_across_steps_when_no_path(self, factory): + """skip_store(path=None)下同一对象可跨 step 复用,首次 transform 不得使其失效。""" + media = factory() + first = media.transform(step=1, path=None) + second = media.transform(step=2, path=None) + assert first.HasField("payload") + assert second.HasField("payload") + assert first.payload == second.payload + assert first.filename.startswith("001-") + assert second.filename.startswith("002-") diff --git a/tests/unit/sdk/internal/settings/test_settings.py b/tests/unit/sdk/internal/settings/test_settings.py index 5bddb694d..30362aa11 100644 --- a/tests/unit/sdk/internal/settings/test_settings.py +++ b/tests/unit/sdk/internal/settings/test_settings.py @@ -402,6 +402,49 @@ def test_core_section_rule_env_overrides_legacy(monkeypatch): assert settings.core.section_rule == -1 +class TestSkipStore: + """core.skip_store 校验矩阵:仅 online 模式合法""" + + def test_default_false(self): + assert Settings().core.skip_store is False + + def test_online_true_passes(self): + settings = Settings(mode="online", core=Settings.Core(skip_store=True)) + assert settings.core.skip_store is True + + def test_legacy_cloud_alias_with_true_passes(self): + """历史 mode="cloud" 先归一化为 online,skip_store 校验必须兼容。""" + settings = Settings(mode="cloud", core=Settings.Core(skip_store=True)) # type: ignore + assert settings.mode == "online" + assert settings.core.skip_store is True + + @pytest.mark.parametrize("mode", ["local", "offline", "disabled"]) + def test_non_online_true_raises(self, mode): + with pytest.raises(ValidationError): + Settings(mode=mode, core=Settings.Core(skip_store=True)) + + def test_env_var_enables(self, monkeypatch): + monkeypatch.setenv("SWANLAB_CORE_SKIP_STORE", "true") + assert Settings().core.skip_store is True + + def test_global_env_with_offline_raises(self, monkeypatch): + """全局设 True 后 init(mode="offline") 的最终组合仍被拦截。""" + monkeypatch.setenv("SWANLAB_CORE_SKIP_STORE", "true") + with pytest.raises(ValidationError): + Settings(mode="offline") + + def test_merge_downgrade_raises(self): + """merge_settings 重建实例会重跑校验,交互降级路径被拦截。""" + settings = Settings(mode="online", core=Settings.Core(skip_store=True)) + with pytest.raises(ValidationError): + settings.merge_settings({"mode": "offline"}) + + def test_to_core_proto_passthrough(self, tmp_path): + settings = Settings(mode="online", core=Settings.Core(skip_store=True)) + proto = settings.to_core_proto(run_id="r1", run_dir=tmp_path) + assert proto.skip_store is True + + @pytest.fixture def netrc_file(tmp_path, monkeypatch): """