also looking at this
Add deterministic test sharding for distributed CI
#22
10 files
+322
-5
| @@ -0,0 +1,87 @@ | ||
| 1 | const std = ; | |
| 2 | ||
| 3 | pub const ShardError = error{ | |
| 4 | InvalidShardCount, | |
| 5 | InvalidShardIndex, | |
| 6 | }; | |
| 7 | ||
| 8 | /// A one-based shard selection suitable for CI matrix values. | |
| 9 | pub const ShardOptions = struct { | |
| 10 | index: usize, | |
| 11 | count: usize, | |
| 12 | ||
| 13 | pub fn validate(self: ShardOptions) ShardError!void { | |
| 14 | if (self.count == 0) return ShardError.InvalidShardCount; | |
| 15 | if (self.index == 0 or self.index > self.count) return ShardError.InvalidShardIndex; | |
| 16 | } | |
| 17 | ||
| 18 | pub fn includes(self: ShardOptions, identity: []const u8) ShardError!bool { | |
| 19 | try self.validate(); | |
| 20 | return (try shardIndex(identity, self.count)) + 1 == self.index; | |
| 21 | } | |
| 22 | }; | |
| 23 | ||
| 24 | /// Assign a normalized test identity to a zero-based shard using FNV-1a 64. | |
| 25 | /// Path separators are normalized so the same relative path maps identically | |
| 26 | /// on Unix and Windows. The algorithm and seed are intentionally fixed. | |
| 27 | pub fn shardIndex(identity: []const u8, shard_count: usize) ShardError!usize { | |
| 28 | if (shard_count == 0) return ShardError.InvalidShardCount; | |
| 29 | ||
| 30 | var hash: u64 = 14_695_981_039_346_656_037; | |
| 31 | for (identity) |byte| { | |
| 32 | const normalized = if (byte == '\\') @as(u8, '/') else byte; | |
| 33 | hash ^= normalized; | |
| 34 | hash *%= 1_099_511_628_211; | |
| 35 | } | |
| 36 | ||
| 37 | const count: u64 = @intCast(shard_count); | |
| 38 | return @intCast(hash % count); | |
| 39 | } | |
| 40 | ||
| 41 | test "fixed identities have stable shard assignments" { | |
| 42 | try std.testing.expectEqual(@as(usize, 1), try shardIndex("tests/api.test.zig", 4)); | |
| 43 | try std.testing.expectEqual(@as(usize, 1), try shardIndex("tests/db.test.zig", 4)); | |
| 44 | try std.testing.expectEqual(@as(usize, 2), try shardIndex("tests/unit/math.test.zig", 4)); | |
| 45 | } | |
| 46 | ||
| 47 | test "path separators do not change shard assignment" { | |
| 48 | try std.testing.expectEqual( | |
| 49 | try shardIndex("tests/unit/math.test.zig", 7), | |
| 50 | try shardIndex("tests\\unit\\math.test.zig", 7), | |
| 51 | ); | |
| 52 | } | |
| 53 | ||
| 54 | test "every identity belongs to exactly one shard" { | |
| 55 | const identities = [_][]const u8{ | |
| 56 | "tests/api.test.zig", | |
| 57 | "tests/db.test.zig", | |
| 58 | "tests/unit/math.test.zig", | |
| 59 | "tests/unit/string.test.zig", | |
| 60 | }; | |
| 61 | ||
| 62 | for (identities) |identity| { | |
| 63 | var matches: usize = 0; | |
| 64 | for (1..5) |index| { | |
| 65 | if (try (ShardOptions{ .index = index, .count = 4 }).includes(identity)) { | |
| 66 | matches += 1; | |
| 67 | } | |
| 68 | } | |
| 69 | try std.testing.expectEqual(@as(usize, 1), matches); | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | test "invalid shard options are rejected" { | |
| 74 | try std.testing.expectError( | |
| 75 | ShardError.InvalidShardCount, | |
| 76 | (ShardOptions{ .index = 1, .count = 0 }).validate(), | |
| 77 | ); | |
| 78 | try std.testing.expectError(ShardError.InvalidShardCount, shardIndex("test.zig", 0)); | |
| 79 | try std.testing.expectError( | |
| 80 | ShardError.InvalidShardIndex, | |
| 81 | (ShardOptions{ .index = 0, .count = 2 }).validate(), | |
| 82 | ); | |
| 83 | try std.testing.expectError( | |
| 84 | ShardError.InvalidShardIndex, | |
| 85 | (ShardOptions{ .index = 3, .count = 2 }).validate(), | |
| 86 | ); | |
| 87 | } | |