|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.Linq; |
| 4 | +using System.Reflection; |
| 5 | +using Microsoft.Extensions.Logging; |
| 6 | +using Modulus.Sdk; |
| 7 | +using NuGet.Versioning; |
| 8 | + |
| 9 | +namespace Modulus.Core.Runtime; |
| 10 | + |
| 11 | +/// <summary> |
| 12 | +/// Builds a unified dependency graph across loaded runtime modules using |
| 13 | +/// manifest dependencies (id + version range) and [DependsOn] attributes |
| 14 | +/// declared on IModule implementations. |
| 15 | +/// </summary> |
| 16 | +public static class RuntimeDependencyGraph |
| 17 | +{ |
| 18 | + public static IReadOnlyList<RuntimeModuleHandle> TopologicallySort( |
| 19 | + IEnumerable<RuntimeModuleHandle> handles, |
| 20 | + ILogger? logger = null) |
| 21 | + { |
| 22 | + var handleList = handles.ToList(); |
| 23 | + if (handleList.Count == 0) |
| 24 | + { |
| 25 | + return Array.Empty<RuntimeModuleHandle>(); |
| 26 | + } |
| 27 | + |
| 28 | + // Map module instance type -> module id for fast resolution of DependsOn targets. |
| 29 | + var typeOwnerMap = BuildTypeOwnerMap(handleList); |
| 30 | + |
| 31 | + var nodes = new List<Node>(handleList.Count); |
| 32 | + foreach (var handle in handleList) |
| 33 | + { |
| 34 | + var moduleId = handle.RuntimeModule.Descriptor.Id; |
| 35 | + var deps = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| 36 | + |
| 37 | + // 1) Manifest dependency edges (id + version range). |
| 38 | + foreach (var (dependencyId, dependencyRange) in handle.Manifest.Dependencies) |
| 39 | + { |
| 40 | + ValidateDependencyVersion(handle, dependencyId, dependencyRange, handleList, logger); |
| 41 | + deps.Add(dependencyId); |
| 42 | + } |
| 43 | + |
| 44 | + // 2) [DependsOn] edges between modules. |
| 45 | + foreach (var moduleInstance in handle.ModuleInstances) |
| 46 | + { |
| 47 | + var dependsOnAttrs = moduleInstance.GetType().GetCustomAttributes<DependsOnAttribute>(); |
| 48 | + foreach (var attr in dependsOnAttrs) |
| 49 | + { |
| 50 | + foreach (var depType in attr.DependedModuleTypes) |
| 51 | + { |
| 52 | + if (!TryResolveModuleForType(depType, typeOwnerMap, out var targetModuleId)) |
| 53 | + { |
| 54 | + logger?.LogError("Module {ModuleId} depends on {DependencyType} which is not loaded.", moduleId, depType.FullName ?? depType.Name); |
| 55 | + throw new InvalidOperationException($"Missing dependency type '{depType.FullName ?? depType.Name}' for module '{moduleId}'."); |
| 56 | + } |
| 57 | + deps.Add(targetModuleId); |
| 58 | + } |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + nodes.Add(new Node(handle, deps)); |
| 63 | + } |
| 64 | + |
| 65 | + var sorted = ModuleDependencyResolver.TopologicallySort( |
| 66 | + nodes, |
| 67 | + n => n.Handle.RuntimeModule.Descriptor.Id, |
| 68 | + n => n.Dependencies, |
| 69 | + logger); |
| 70 | + |
| 71 | + return sorted.Select(n => n.Handle).ToList(); |
| 72 | + } |
| 73 | + |
| 74 | + private static void ValidateDependencyVersion( |
| 75 | + RuntimeModuleHandle sourceHandle, |
| 76 | + string dependencyId, |
| 77 | + string dependencyRange, |
| 78 | + IReadOnlyList<RuntimeModuleHandle> handles, |
| 79 | + ILogger? logger) |
| 80 | + { |
| 81 | + var sourceId = sourceHandle.RuntimeModule.Descriptor.Id; |
| 82 | + var targetHandle = handles.FirstOrDefault(h => dependencyId.Equals(h.RuntimeModule.Descriptor.Id, StringComparison.OrdinalIgnoreCase)); |
| 83 | + if (targetHandle == null) |
| 84 | + { |
| 85 | + logger?.LogError("Module {ModuleId} declares dependency {DependencyId} which is not loaded.", sourceId, dependencyId); |
| 86 | + throw new InvalidOperationException($"Missing dependency '{dependencyId}' for module '{sourceId}'."); |
| 87 | + } |
| 88 | + |
| 89 | + if (!NuGetVersion.TryParse(targetHandle.RuntimeModule.Descriptor.Version, out var dependencyVersion)) |
| 90 | + { |
| 91 | + logger?.LogError("Module {ModuleId} dependency {DependencyId} has invalid version {DependencyVersion}.", sourceId, dependencyId, targetHandle.RuntimeModule.Descriptor.Version); |
| 92 | + throw new InvalidOperationException($"Dependency '{dependencyId}' version '{targetHandle.RuntimeModule.Descriptor.Version}' is not a valid SemVer."); |
| 93 | + } |
| 94 | + |
| 95 | + if (!VersionRange.TryParse(dependencyRange, out var range)) |
| 96 | + { |
| 97 | + logger?.LogError("Module {ModuleId} dependency {DependencyId} has invalid version range {Range}.", sourceId, dependencyId, dependencyRange); |
| 98 | + throw new InvalidOperationException($"Dependency '{dependencyId}' range '{dependencyRange}' is not a valid SemVer range."); |
| 99 | + } |
| 100 | + |
| 101 | + if (!range.Satisfies(dependencyVersion)) |
| 102 | + { |
| 103 | + logger?.LogError("Module {ModuleId} dependency {DependencyId} version {DependencyVersion} does not satisfy range {Range}.", sourceId, dependencyId, dependencyVersion, dependencyRange); |
| 104 | + throw new InvalidOperationException($"Dependency '{dependencyId}' version '{dependencyVersion}' does not satisfy '{dependencyRange}'."); |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + private static bool TryResolveModuleForType( |
| 109 | + Type dependencyType, |
| 110 | + IReadOnlyDictionary<Type, string> typeOwnerMap, |
| 111 | + out string moduleId) |
| 112 | + { |
| 113 | + moduleId = string.Empty; |
| 114 | + |
| 115 | + if (typeOwnerMap.TryGetValue(dependencyType, out var mapped)) |
| 116 | + { |
| 117 | + moduleId = mapped; |
| 118 | + return true; |
| 119 | + } |
| 120 | + |
| 121 | + // Fallback by FullName match in case types come from different load contexts but share name. |
| 122 | + var match = typeOwnerMap.FirstOrDefault(kvp => string.Equals(kvp.Key.FullName, dependencyType.FullName, StringComparison.OrdinalIgnoreCase)); |
| 123 | + if (!string.IsNullOrEmpty(match.Value)) |
| 124 | + { |
| 125 | + moduleId = match.Value!; |
| 126 | + return true; |
| 127 | + } |
| 128 | + |
| 129 | + return false; |
| 130 | + } |
| 131 | + |
| 132 | + private static Dictionary<Type, string> BuildTypeOwnerMap(IEnumerable<RuntimeModuleHandle> handles) |
| 133 | + { |
| 134 | + var map = new Dictionary<Type, string>(); |
| 135 | + foreach (var handle in handles) |
| 136 | + { |
| 137 | + var moduleId = handle.RuntimeModule.Descriptor.Id; |
| 138 | + foreach (var moduleInstance in handle.ModuleInstances) |
| 139 | + { |
| 140 | + var type = moduleInstance.GetType(); |
| 141 | + // Last write wins; assemblies should be unique per module but we prefer predictable behavior. |
| 142 | + map[type] = moduleId; |
| 143 | + } |
| 144 | + } |
| 145 | + return map; |
| 146 | + } |
| 147 | + |
| 148 | + private sealed record Node(RuntimeModuleHandle Handle, HashSet<string> Dependencies); |
| 149 | +} |
| 150 | + |
0 commit comments