Skip to content

Add invalidation stress test. #4010

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions build/lib/src/library_cycle_graph/asset_deps_loader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,20 @@ class _InMemoryAssetDepsLoader implements AssetDepsLoader {
);
PhasedAssetDeps phasedAssetDeps;

_InMemoryAssetDepsLoader(this.phasedAssetDeps);

// This is important: it prevents LibraryCycleGraphLoader from trying to load
// data that is not in an incomplete [phasedAssetDeps].
_InMemoryAssetDepsLoader(PhasedAssetDeps phasedAssetDeps)
: phasedAssetDeps = phasedAssetDeps.complete();

// Return very high phase to tell `LibraryCycleGraphLoader` that all data is
// available.
//
// Returning incomplete data would then cause `LibraryCycleGraphLoader` to
// get stuck, which is why `phasedAssetDeps.complete` was called to mark
// all the data complete.
//
// This loader is only used for rebuilding graphs constructed in an earlier
// run, so incomplete data won't actually be used.
@override
int get phase => phasedAssetDeps.phase;
int get phase => 0xffffffff;

@override
ExpiringValue<AssetDeps> _parse(AssetId id, ExpiringValue<String> content) =>
Expand Down
32 changes: 21 additions & 11 deletions build/lib/src/library_cycle_graph/library_cycle_graph_loader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ class LibraryCycleGraphLoader {
(_idsToLoad[phase] ??= []).add(id);
}

void _loadAllAtPhase(int phase, Iterable<AssetId> ids) {
void _loadAllAtPhaseZero(Iterable<AssetId> ids) {
if (ids.isEmpty) return;
(_idsToLoad[phase] ??= []).addAll(ids);
(_idsToLoad[0] ??= []).addAll(ids);
}

/// Whether there are assets to load before or at [upToPhase].
Expand Down Expand Up @@ -144,17 +144,27 @@ class LibraryCycleGraphLoader {
///
/// Pass a phase and ID from [_nextIdToLoad].
void _removeIdToLoad(int phase, AssetId id) {
// A recursive load might have updated `_idsToLoad` since `_nextIdToLoad`
// was called. If so it fully processed some phases: either `_idsToLoad` is
// now empty at `phase`, in which case there is nothing to do, or it's
// unchanged, in which case `id` is still the last ID.
final ids = _idsToLoad[phase];
if (ids != null) {
if (ids.removeLast() != id) {
throw StateError('$id should still be last in _idsToLoad[$phase]');

// It's possible that a recursive call to an earlier phase fully processed
// the phase, leaving nothing to clean up.
if (ids == null) {
return;
}

// It's possible that a recursive call to an earlier phase encountered a
// reference to an asset generated at this phase, and so added another
// asset to load in this phase. In that case `id` is no longer the last in
// the list: search the whole list to remove it.
if (ids.last == id) {
ids.removeLast();
} else {
final removed = ids.remove(id);
if (!removed) {
throw StateError('Failed to remove $id from _idsToLoad: $_idsToLoad');
}
if (ids.isEmpty) _idsToLoad.remove(phase);
}
if (ids.isEmpty) _idsToLoad.remove(phase);
}

/// Loads [id] and its transitive dependencies at all phases available to
Expand Down Expand Up @@ -221,7 +231,7 @@ class LibraryCycleGraphLoader {
// for loading at any phase: if the `_load` that loads them is at a too
// early phase to see generated output they will be queued for
// processing by a later `_load`.
_loadAllAtPhase(0, assetDeps.lastValue.deps);
_loadAllAtPhaseZero(assetDeps.lastValue.deps);
} else {
// It's a generated source that has not yet been generated. Mark it for
// loading later.
Expand Down
25 changes: 6 additions & 19 deletions build/lib/src/library_cycle_graph/phased_asset_deps.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:math';

import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
Expand All @@ -29,9 +27,6 @@ abstract class PhasedAssetDeps
_$PhasedAssetDeps;
PhasedAssetDeps._();

factory PhasedAssetDeps.of(Map<AssetId, PhasedValue<AssetDeps>> assetDeps) =>
_$PhasedAssetDeps._(assetDeps: assetDeps.build());

/// Returns `this` data with [other] added to it.
///
/// For each asset: if [other] has a complete value for that asset, use the
Expand All @@ -53,20 +48,12 @@ abstract class PhasedAssetDeps
return result.build();
}

/// The max phase before there is any incomplete data, or 0xffffffff if there
/// is no incomplete data.
@memoized
int get phase {
int? result;
for (final entry in assetDeps.values) {
if (!entry.isComplete) {
if (result == null) {
result = entry.expiresAfter;
} else {
result = min(result, entry.expiresAfter!);
}
PhasedAssetDeps complete() => rebuild((b) {
for (final entry in assetDeps.entries) {
final value = entry.value;
if (!value.isComplete) {
b.assetDeps[entry.key] = PhasedValue.fixed(value.values.single.value);
}
}
return result ?? 0xffffffff;
}
});
}
4 changes: 0 additions & 4 deletions build/lib/src/library_cycle_graph/phased_asset_deps.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

146 changes: 146 additions & 0 deletions build_runner_core/test/invalidation/invalidation_stress_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:math';

import 'package:build/build.dart' show AssetId;
import 'package:test/test.dart';

import 'invalidation_tester.dart';

/// Tests correctness of invalidation over many randomly generated scenarios.
///
/// The test builders write output that is a list of files read/resolved and their
/// content hashes. This does two things: it ensures that if any input changes,
/// the output changes; and it makes it possible to determine what will
/// invalidate that output.
///
/// In this way the test can know what output changes to assert due to a
/// particular input change.
Future<void> main() async {
for (var iteration = 0; iteration != 500; ++iteration) {
test('invalidation stress test $iteration', () async {
final tester = InvalidationTester()..logSetup();
final random = Random(iteration);

// Whether to change the imports in source files between builds.
final changeImports = iteration % 10 == 0;

// How many checked in sources and how many builders; the build is
// a rectangle of size numberOfSources x numberOfBuilders.
final numberOfSources = random.nextInt(8) + 1;
final numberOfBuilders = random.nextInt(4) + 1;

final sources = [
for (var i = 0; i != numberOfSources; ++i) 'a${i + 1}.1',
];

// Inputs that can be randomly read/resolved.
// `numberOfBuilders + 2` to add some reads/resolves of files that
// don't exist.
final pickableInputs = <String>[];
for (var i = 1; i != (numberOfBuilders + 2); ++i) {
for (final source in sources) {
pickableInputs.add(source.replaceAll('.1', '.$i'));
}
}

// All outputs.
final outputs = <String>[];
for (var i = 2; i != (numberOfBuilders + 1); ++i) {
for (final source in sources) {
outputs.add(source.replaceAll('.1', '.$i'));
}
}

// Picks a list of files to import from `pickableInputs`.
List<String> randomImportList() {
final result = <String>[];
final length = random.nextInt(numberOfSources);
for (var i = 0; i != length; ++i) {
result.add(pickableInputs[random.nextInt(pickableInputs.length)]);
}
return result;
}

tester
..sources(sources)
..pickableInputs(pickableInputs);

// Set up builders.
for (var i = 1; i != numberOfBuilders; ++i) {
tester.builder(
from: '.$i',
to: '.${i + 1}',
// Cover optional+required builders.
isOptional: random.nextBool(),
// Cover builders with hidden+visible output.
outputIsVisible: random.nextBool(),
)
// Use the input as the seed for what additional reads to do,
// so reads won't change between identical runs.
..readsForSeedThenReadsRandomly('.$i')
..writes('.${i + 1}');
}

// Initial random import graph.
final importGraph = {
for (var source in pickableInputs) source: randomImportList(),
};
tester.importGraph(importGraph);

// Initial build should succeed.
expect((await tester.build()).succeeded, true);

// Do five additional builds making changes and checking what was written.
for (var build = 0; build != 5; ++build) {
// Pick which source to change, compute expected outputs.
final sourceToChange = sources[random.nextInt(sources.length)];
final expectedOutputs = <String>{};

Future<void> addExpectedOutputs(String invalidatedInput) async {
for (final output in outputs) {
final assetId = AssetId('pkg', 'lib/$output.dart');
final hiddenAssetId = AssetId(
'pkg',
'.dart_tool/build/generated/pkg/lib/$output.dart',
);
final outputContents =
tester.readerWriter!.testing.exists(assetId)
? tester.readerWriter!.testing.readString(assetId)
: tester.readerWriter!.testing.exists(hiddenAssetId)
? tester.readerWriter!.testing.readString(hiddenAssetId)
: '';
// The test builder output is a list of "$name,$hash" for each input
// that was read, including transitively resolved sources. Check it
// for [input]. If found, this output is invalidated: recursively
// add its invalidated outputs.
if (outputContents.contains('$invalidatedInput.dart,')) {
if (expectedOutputs.add(output)) {
await addExpectedOutputs(output);
}
}
}
}

await addExpectedOutputs(sourceToChange);

// If [changeImports] then change some imports; it's only possible to
// change imports for files that will be output.
if (changeImports && expectedOutputs.isNotEmpty) {
final sourceToChangeImports =
expectedOutputs.toList()[random.nextInt(expectedOutputs.length)];
importGraph[sourceToChangeImports] = randomImportList();
tester.importGraph(importGraph);
}

// Build and check exactly the expected outputs change.
expect(
await tester.build(change: sourceToChange),
Result(written: expectedOutputs),
);
}
});
}
}
Loading
Loading