CP-312874 SMAPIv3 storage-layer engine for SXM v3 snapshot migration - #7223
LunfanZhang merged 5 commits into
Conversation
| module MIRROR : SMAPIv2_MIRROR = struct | ||
| type context = unit | ||
|
|
||
| let send_start _ctx ~dbg ~task_id:_ ~dp ~sr ~vdi ~image_format ~mirror_vm |
There was a problem hiding this comment.
send_start now blocks synchronously for the whole tree. Have you estimated the time it takes is acceptable when the tree is deep and wide?
There was a problem hiding this comment.
Were confounding removed from the comparison? Like using different hardware disks for both tests
There was a problem hiding this comment.
Good question - Both sources are now local SSD on hardware-identical hosts. The two hosts are the same model throughout: AMD EPYC 9124 16-Core (32 CPU, 3.0 GHz), 137 GB RAM, XenServer 9.0.0 same build, and — directly on your point — the local SR on both hosts is the same disk, sda = PERC H755N Front, 1.5 TB, non-rotational, behind an identical Broadcom MegaRAID SAS39xx controller. The xfs (V3) and ext4 (V1) SRs are filesystems on the same disk model and controller, not different hardware tiers.
f687782 to
c91f92c
Compare
| let set_snapshot_mappings mirror_id relations = | ||
| Xapi_stdext_threads.Threadext.Mutex.execute mutex (fun () -> | ||
| Hashtbl.replace snapshot_mappings mirror_id relations | ||
| ) | ||
|
|
||
| let get_snapshot_mappings mirror_id = | ||
| Xapi_stdext_threads.Threadext.Mutex.execute mutex (fun () -> | ||
| Hashtbl.find_opt snapshot_mappings mirror_id |> Option.value ~default:[] | ||
| ) | ||
|
|
||
| let remove_snapshot_mappings mirror_id = | ||
| Xapi_stdext_threads.Threadext.Mutex.execute mutex (fun () -> | ||
| Hashtbl.remove snapshot_mappings mirror_id | ||
| ) |
There was a problem hiding this comment.
Can we create a separate mutex for the snapshot_mappings table, rather than reuse the access_table mutex? It seems to me that they are independent, and this creates unnecessary contention.
There was a problem hiding this comment.
Good catch. update to use a new snapshot_mappings_m rather than reuse the mutex .
32bce32 to
9013ff6
Compare
|
Generally, I prefer changing the way how to iterate individual VDIs to introducing the whole VDI tree when handling an active VDI. |
send_start and receive_start3 spell out between them how a mirror is set up: the path the nbd proxy listens on, the nbd URI built from it, the thread that serves the proxy, and the export read out of the attach info. Copying a snapshot chain is about to need all four again, so give each of them a name first. Waiting for a mirror to finish becomes wait_for_mirror. It takes the message to fail with, and the mirror id whose send state it marks as failed is now optional, so a copy that has no entry there can wait the same way. Two changes in behaviour come with the move. The wait loop sleeps between polls instead of asking the storage layer as fast as it can answer, and a Storage_error raised under send_start is re-raised unchanged rather than flattened into a mirror failure, so the reason a mirror never started survives the trip back to the caller. Signed-off-by: Lunfan Zhang[Lunfan.Zhang] <Lunfan.Zhang@cloud.com>
start_nbd_proxy_thread hands back a thread and nothing else, and the first thing that thread does is bind and listen on the socket path the caller is about to give qemu-dp. Whether the listener is up in time is left to the scheduler. Bind and listen on the caller's thread instead and pass the socket in, leaving accept and proxy to the thread. Once listen has returned the kernel queues the connection in the backlog, so it no longer matters where the thread has got to, and a bind that fails now fails the caller rather than killing a detached thread. Signed-off-by: Lunfan Zhang[Lunfan.Zhang] <Lunfan.Zhang@cloud.com>
0eeb342 to
ff6cdb3
Compare
@minglumlu that is what the latest push does. There is no VDI tree any more:
vdi_mirror gains filled by
position numbers all_vdis in DFS pre-order over those edges (roots = no parent, or a parent that is not in the For a VM with disks A and B, four snapshots each, A smaller than B: The invariant this buys: a VDI is always copied after the VDI it follows.
with_many already accumulated results and passed them to the final continuation; it just never gave them to the That accumulator is
Because of (2), the parent is already in done_map by the time a node is reached, so the copy lands on it and the
The base is passed down via the new DATA.copy2: SMAPIv1 finds its own base through Storage_smapiv3_migrate.get_snapshot_tree and the State.snapshot_mappings side table are both gone — no |
| ; snapshot_of: [`VDI] API.Ref.t (** API's snapshot_of reference *) | ||
| ; snapshot_parent: [`VDI] API.Ref.t option | ||
| (** The VDI this one directly follows in its disk's snapshot chain *) | ||
| ; do_mirror: bool (** Whether we should mirror or just copy the VDI *) |
There was a problem hiding this comment.
Now should it be is_active_leaf for being more accurate?
There was a problem hiding this comment.
I think snapshot_parent is accurate expression here.
| mr.mr_local_vdi_reference = parent && mr.mr_remote_sr = dest_sr | ||
| ) | ||
| done_map | ||
| |> Option.map (fun mr -> mr.mr_remote_vdi) |
There was a problem hiding this comment.
It should be a bug in sorting VDIs when can't finding mirrored/copied parent on destination side. It should fail.
There was a problem hiding this comment.
Agreed — Option.bind silently swallows the case where the parent cannot be found, which should abort the migration with an explicit error instead.
Changed Option.bind to Option.map, so None can now only mean "this VDI is the root of its chain"; a parent that has not been copied raises instead.
| @-> vm_p | ||
| @-> url_p | ||
| @-> dest_p | ||
| @-> dest_base_p |
There was a problem hiding this comment.
How about dest_sr_p and dest_vdi_p?
There was a problem hiding this comment.
dest_p is pre-existing: it is declared for DATA.copy and shared by copy2. I would rather not rename.
For dest_base_p I would prefer to keep the name. dest_vdi_p is already taken by another place (e.g., https://github.com/xapi-project/xen-api/blob/master/ocaml/xapi-idl/storage/storage_interface.ml#L733) and means the opposite. dest_base_p means a base parent here which sounds reasonable.
|
|
||
| (** [copy] with the base of the destination copy given by the caller | ||
| rather than found by the backend. *) | ||
| let copy2 = |
There was a problem hiding this comment.
This is not related with this change.
But I think it's worth adding a comment for the local parent so that the semantic of the copy would be to copy all delta changes between (parent_vdi, vdi]. It is not used so far because for SMAPIv1, the parent is retrieved (layer-violation) again in vhd-tool, and current SMAPIv3 mirror doesn't support it. When the parent is None, it means the underline storage implementation needs to retrieve all changes up to the hidden root VDI. Lacking of this actually introduces some limitations - we have to assume there are no hidden VDIs between the visible VDIs in storage implementation.
So adding it now can make it easier for future improvements.
There was a problem hiding this comment.
Yes, copy2 pins the destination base but leaves the source-side delta base to the backend, which derives it from
the source chain itself and therefore assumes no hidden VDI in between.
I have written that down on copy2.
but I would rather not add the parameter in this PR. One that no backend impl is a trap — Some p reads as a promise and would be silently ignored. I would prefer to add it together with the backend change that can actually use it.
|
Hi @LunfanZhang |
gthvn1
left a comment
There was a problem hiding this comment.
Within the limits of my skills, as this code goes beyond them, I don't spot any errors. Congrats for the great work!
Thanks @minglumlu for your time to review this. I believe for the both SMAPIv1 and SMAPIv3 should apply the algorithm to DFS within the tree from top to down, and sort each trees with leaf size. I can keep the same for v1 trees to ensure no regression, but as I mentioned at : #7223 (comment), there is bug in current SMAPIv1 algorithm, so although it takes some risk, I think it`d better to change the SMAPIv1. |
ff6cdb3 to
34d3b59
Compare
| | None -> | ||
| true | ||
| | Some parent -> | ||
| not (List.exists (fun v -> v.vdi = parent) all_vdis) |
There was a problem hiding this comment.
This means there is a parent which is not in all_vdis. SMAPIv1 will support it?
There was a problem hiding this comment.
lineage_parent_of walks the VM.parent chain, which only contains this VM's own snapshots, and their disks are all in all_vdis.
A invalid VM.parent is already turned into None by the is_valid_ref check at the top of that function. So neither backend (v3 or v1) can reach this branch — it is defensive only.
Even there is hidden VDI, it does matter, I think SMAPIv1 do not care too much about the order as it check the parent directly with similar_content with each Copy
There was a problem hiding this comment.
This would go through the whole all_vdis again.
| in | ||
| let is_snapshot v = Db.is_valid_ref __context v.snapshot_of in | ||
| let children_of vconf = | ||
| List.filter (fun v -> v.snapshot_parent = Some vconf.vdi) all_vdis |
There was a problem hiding this comment.
This is of O(N^2) complexity. It can be avoided.
| compare t1 t2 | ||
| else | ||
| r | ||
| match Int64.compare v1.size v2.size with |
There was a problem hiding this comment.
This is the original code. But I can't understand it. I would think the key for comparison is something like:
- vconf.snapshot_of - for different trees
- depth in the same tree
- is_snapshot
- age
There was a problem hiding this comment.
The original logic is to transfer the small size VDI first, which then mirror the leaf first, thats why the tree is separate in SMAPIv1. From my perspective, dividing different tree with vconf.snapshot_of ` and compare the age for snapshot , and put the leaf at the end of snapshot set is enough. which is similar to a round of DFS. but DFS is more strict.
There was a problem hiding this comment.
Then it can be changed to smaller tree (the size of the active leaf) first?
The approach of comparison should be either splitting between SMAPIv1 and SMAPIv3, or common for both. Such comparison by size does no meaning at all for SMAPIv3. It would make the whole sorting awkward.
There was a problem hiding this comment.
Hi @minglumlu old order had an unstated precondition, that for all the snapshot VDI belong to a tree, it's virtual size is usually the same, we can assume the walk was consulted only between VDIs of identical virtual_size.
and I proposal new order, sort tree first but not VDIs, Which is your suggestion for more strict.
in general we get the root from all_vdis first, and walk each root then sort the tree list base on the tree_size:
List.filter is_root all_vdis
|> List.map (fun root ->
let tree = walk root in
(tree_size tree, tree)
)
|> List.stable_sort (fun (s1, _) (s2, _) -> Int64.compare s1 s2)
|> List.concat_map snd
is_root is "no snapshot_parent, or a parent is in all_vdi". and tree_size impl like tree_size is List.fold_left (fun acc v -> Int64.max acc v.size) 0L tree which filter the max chain node for comparing.
and for tree, first create a table to reflect the parent-children:
let children =
List.fold_left
(fun acc v ->
match v.snapshot_parent with
| Some parent when VdiSet.mem parent present ->
VdiMap.update parent
(fun siblings -> Some (v :: Option.value ~default:[] siblings))
acc
| _ ->
acc
)
VdiMap.empty eldest_first
|> VdiMap.map List.rev
and walk the tree:
let rec walk v =
v
:: (VdiMap.find_opt v.vdi children
|> Option.value ~default:[]
|> List.concat_map walk
)
in
children is a persistent Map built by one fold and never touched again; walk returns its subtree rather than writing into an index, so no Hashtbl and the int ref.
Sibling order is settled once by eldest_first sorting on a key (not is_snapshot, snapshot_time)
- snapshots before the active disk,
- oldest first
For a example as we mentioned before, 4 snapshots 2 disk, now the final order is:
[S_A1 S_A2 S_A3 S_A4 A_leaf] [S_B1 S_B2 S_B3 S_B4 B_leaf]
\______ tree A, 10G __________/ \______ tree B, 20G __________/
and current time complexity is reduce from O(N²) to O(N log N).
| (fun active -> | ||
| Db.VDI.get_snapshots ~__context ~self:active | ||
| |> List.filter (fun s -> | ||
| (not (List.mem s vm_snapshot_disks)) && is_v3_vdi s |
There was a problem hiding this comment.
When the active is on a SMAPIv3 SR, all its snapshots will be in SMAPIv3 SR?
I think it is true. If so, is_v3_vdi can be avoided on each snapshot.
There was a problem hiding this comment.
I make some change, so the is_v3_vdi is only asked at the roots only.
is_v3_vdi now appears exactly twice in the function, both times as a List.filter at the top of a pipeline, and never inside hidden_below — one query per migrated disk instead of one per VDI in the tree. A tree rooted on a SMAPIv1 SR is dropped at the root and never walked, so the assert stays SMAPIv3-only.
| ) | ||
| |> List.map (fun s -> (s, active)) | ||
| ) | ||
| active_disks |
There was a problem hiding this comment.
Only active disks can have the hidden VDI snapshots? I think the snapshots VDI can have as well.
There was a problem hiding this comment.
snapshot VDI can have hidden VDI as well. I confirmed it on a pool for both ext and gfs2. and now I turn the walk to the whole subtree:
let rec hidden_below origin =
Db.VDI.get_snapshots ~__context ~self:origin
|> List.concat_map (fun s ->
if List.mem s snapshot_vdis then [] else (s, origin) :: hidden_below s
)
in
let hidden =
active_vdis @ snapshot_vdis
|> List.sort_uniq compare
|> List.filter is_v3_vdi
|> List.concat_map hidden_below
in
| ) | ||
| in | ||
| let orphans = | ||
| List.filter (fun s -> is_v3_vdi s && orphaned s) snapshot_vdis |
There was a problem hiding this comment.
Same comment for the is_v3_vdi.
There was a problem hiding this comment.
for the orphans snapshots case, is_v3_vdi only sees what survives it — on a healthy VM, no node would run the check:
let origin_of s = Db.VDI.get_snapshot_of ~__context ~self:s in
let orphans =
snapshot_vdis
|> List.filter (fun s -> not (List.mem (origin_of s) active_vdis))
|> List.filter is_v3_vdi
in
| List.filter (fun s -> is_v3_vdi s && orphaned s) snapshot_vdis | ||
| in | ||
| abort | ||
| "it has orphan snapshot VDIs on SMAPIv3 SRs whose active disk was deleted; \ |
There was a problem hiding this comment.
I think the checking is that a snapshot has an active leaf but
- the active leaf is invalid; or
- the active leaf is not in active leafs to be migrated.
The error message should split these two cases.
There was a problem hiding this comment.
Agreed. That single filter finds both — a dangling ref can never equal a live VDI's ref — and List.partition only has to decide which message applies:
let deleted, detached =
List.partition
(fun s -> not (Db.is_valid_ref __context (origin_of s)))
orphans
in
- deleted — snapshot_of no longer resolves; the active disk was destroyed and there is nothing at the destination to anchor the chain on. "…whose active disk has been deleted; delete these snapshots before migrating".
- detached — snapshot_of resolves, but that VDI is not part of this migration. "…whose active disk is not part of this migration", and the message names the disk.
A SMAPIv3 destination is rebuilt from the VM's snapshot tree, so a VDI that the tree does not account for has nowhere to go. Two of them can reach the migration path. A snapshot taken with VDI.snapshot belongs to no VM snapshot and is invisible to the walk. A snapshot VDI whose active disk has been deleted has no leaf left to anchor its chain on. Check for both before anything is copied and refuse the migration with operation_not_allowed, naming the VDIs at fault and what to do about them, so the VM keeps running where it is rather than arriving with a tree the destination cannot describe. Signed-off-by: Lunfan Zhang[Lunfan.Zhang] <Lunfan.Zhang@cloud.com>
SXM reproduces a VM's snapshot chain on the destination one VDI at a time, each copy sitting on top of the copy of the snapshot before it. SMAPIv1 finds that base itself, by matching the source VDI's similar content against the content_ids already present in the destination SR. SMAPIv3 backends report no similar content, so the base has to come from the caller, which knows the chain from the VM's snapshot lineage. DATA.copy2 is DATA.copy with that base made explicit. Storage_migrate dispatches on the SR's SMAPI version: SMAPIv1 keeps its own search and ignores the parameter; SMAPIv3 clones the base (or creates a blank VDI when there is none), mirrors the source VDI into the clone over the NBD proxy, and freezes the result into a snapshot. DATA.MIRROR.receive_start3 takes the same parameter, for the leaf VDI it creates. Nothing supplies a base yet; the VM migration path does that next. Signed-off-by: Lunfan Zhang[Lunfan.Zhang] <Lunfan.Zhang@cloud.com>
34d3b59 to
f225618
Compare
minglumlu
left a comment
There was a problem hiding this comment.
Just a few comments for style.
f225618 to
ccff5f6
Compare
A SMAPIv1 destination gets a VM's snapshot chains for free: each copy is based on whichever VDI already in the destination SR has the most similar content, which is the copy of the snapshot before it. SMAPIv3 backends report no similar content, so every VDI arrives as a full, unrelated copy and the snapshot structure is lost. Migration already copies a VM's VDIs one at a time, in a with_many fold, and DATA.copy2 now takes the destination VDI a copy should be based on. with_many hands each element the results of the elements before it, so naming the destination copy of the VDI a VDI follows in its disk's snapshot chain is enough to reproduce the chain. That parent comes from the VM snapshot tree: the disk of the nearest ancestor of the VM that has one, so snapshots on a branch the VM has reverted away from start chains of their own. It only works if a VDI is copied after the VDI it is based on, so same-sized VDIs are now ordered by a depth-first walk of the snapshot trees rather than by snapshot time. A disk's tree stays in one piece whatever else is attached to the VM, siblings go oldest first so that the branches a revert abandoned are copied before the branch the VM is still on, and the disk itself, the only child that is not a snapshot, comes last. Signed-off-by: Lunfan Zhang[Lunfan.Zhang] <Lunfan.Zhang@cloud.com>
ccff5f6 to
5b5b474
Compare

SMAPIv1 destinations reproduce a VM's snapshot chains by: each copy lands on whichever destination VDI has the most similar content, which happens to be the copy of the snapshot
before it. SMAPIv3 backends report no similar content, so every VDI arrives as a full, unrelated copy and the structure is lost.
The fix is to let the caller name the base, and have the VM migration path work out what it should be.
DATA.copy2—DATA.copywith an explicitdest_base. SMAPIv1 keeps its own similar_content search and ignores it; SMAPIv3 clones the base, mirrors the source VDI into the clone over the NBD proxy, and freezes the result into a snapshot.snapshot_parent— eachvdi_mirrorrecords the VDI it directly follows, derived bylineage_parent_offrom the VM snapshot tree. A branch the VM has reverted away from starts a chain of its own.Copy order — same-sized VDIs are now ordered by a depth-first walk of those edges rather than by snapshot time, so a VDI is always copied after its base.
with_manyalready hands each VDI the results of the ones before it, so that accumulator is the source→destination mapping; miss parent just means a full copy.Two layouts a SMAPIv3 destination cannot represent:
both are refused with
operation_not_allowedbefore anything is copied.