blob: 3f24eda65efeaffa801fbce105c6f8bcafbfdba2 [file] [log] [blame]
// Copyright 2015 The Bazel Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.devtools.build.android.ziputils;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import com.google.common.collect.Range;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
/**
* Unit tests for {@link Splitter}.
*/
@RunWith(JUnit4.class)
public class SplitterTest {
private static final String ARCHIVE_DIR_SUFFIX = "/";
private static final String ARCHIVE_FILE_SEPARATOR = "/";
private static final String CLASS_SUFFIX = ".class";
@Test
public void testAssign() {
int size = 10;
Collection<String> input;
ArrayList<String> filter;
Map<String, Integer> output;
input = genEntries(size, size);
filter = new ArrayList<>(10);
for (int i = 0; i < size; i++) {
filter.add("dir" + i + ARCHIVE_FILE_SEPARATOR + "file" + i + CLASS_SUFFIX);
}
Splitter splitter = new Splitter(size + 1, input.size());
splitter.assign(filter);
splitter.nextShard();
output = new LinkedHashMap<>();
for (String path : input) {
output.put(path, splitter.assign(path));
}
for (int i = 0; i < size; i++) {
for (int j = 0; j < size; j++) {
String path = "dir" + i + ARCHIVE_FILE_SEPARATOR + "file" + j + CLASS_SUFFIX;
if (i == j) {
assertWithMessage(path).that(output.get(path)).isEqualTo(0);
} else {
assertWithMessage(path).that(output.get(path)).isEqualTo(i + 1);
}
}
}
}
/**
* Test splitting of single-ile packages. Note, this is also testing for the situation
* where input entries are unordered, and thus appearing to be in different packages,
* to the implementation that only confiders the previous file to determine package
* boundaries.
*/
@Test
public void testSingleFilePackages() {
int[][] params = {
{ 1, 1, 1}, // one shard, for one package with one file
{1, 2, 1}, // one shard, for two packages, with one file each
{1, 10, 1}, // one shard, for ten packages, with one file each
{2, 2, 1}, // ...
{2, 10, 1},
{2, 100, 1},
{10, 10, 1},
{10, 15, 1},
{10, 95, 1},
{97, 10000, 1},
};
comboRunner(params);
}
/**
* Test cases where the number of shards is less than the number
* of packages. This implies that the package size is less than
* the average shard size. We expect shards to be multiple of
* package size.
*/
@Test
public void testPackageSplit() {
int[][] params = {
{2, 3, 2}, // two shards, for three packages, with two files each
{2, 3, 9}, // ...
{2, 3, 10},
{2, 3, 11},
{2, 3, 19},
{2, 10, 2},
{2, 10, 9},
{2, 10, 10},
{2, 10, 11},
{2, 10, 19},
{10, 11, 2},
{10, 11, 9},
{10, 11, 10},
{10, 11, 11},
{10, 11, 19},
{10, 111, 2},
{10, 111, 9},
{10, 111, 10},
{10, 111, 11},
{10, 111, 19},
{25, 1000, 8},
{25, 1000, 10},
{25, 1000, 19},
{250, 10000, 19},
};
comboRunner(params);
}
/**
* Tests situations where the number of shards exceeds the number of
* packages (but not the number of files). That is, the implementation
* must split at least one package.
*/
@Test
public void testForceSplit() {
int[][] params = {
{2, 1, 2}, // two shards, for one package, with two files
{2, 1, 9}, // ...
{2, 1, 10},
{2, 1, 11},
{3, 2, 2},
{10, 9, 2},
{10, 2, 9},
{10, 9, 9},
{10, 2, 10},
{10, 9, 10},
{10, 2, 11},
{10, 9, 11},
{10, 2, 111},
{10, 9, 111},
{100, 12, 9},
{100, 12, 9},
{100, 10, 10},
{100, 10, 10},
{100, 10, 11},
{100, 20, 111},
};
comboRunner(params);
}
/**
* Tests situation where the number of shards requested exceeds the
* the number of files. Empty shards are expected.
*/
@Test
public void testEmptyShards() {
int[][] params = {
{2, 1, 1}, // two shards, for one package, with one files
{10, 2, 2},
{100, 10, 9},
{100, 9, 10},
};
comboRunner(params);
}
/**
* Run multiple test for sets of test specifications consisting of
* "number of shards", "number of packages", "package size".
*/
private void comboRunner(int[][] params) {
Collection<String> input;
Map<String, Integer> output;
for (int[] param : params) {
input = genEntries(param[1], param[2]);
output = runOne(param[0], input);
String name = Arrays.toString(param);
splitAsserts(name, param[0], param[1], param[2],
commonAsserts(name, param[0], param[1], param[2], input, output));
}
}
private Map<String, Integer> runOne(int shards, Collection<String> entries) {
Splitter splitter = new Splitter(shards, entries.size());
Map<String, Integer> result = new LinkedHashMap<>();
for (String entry : entries) {
result.put(entry, splitter.assign(entry));
}
return result;
}
private Collection<String> genEntries(int packages, int files) {
List<String> entries = new ArrayList<>();
for (int dir = 0; dir < packages; dir++) {
for (int file = 0; file < files; file++) {
entries.add("dir" + dir + ARCHIVE_FILE_SEPARATOR + "file" + file + CLASS_SUFFIX);
}
}
return entries;
}
private int[] assertAndCountMappings(int shards, int packageSize,
Map<String, Integer> output, boolean expectPackageBoundaryShards) {
int[] counts = new int[shards + 1];
String prevPath = null;
int prev = -2;
for (Map.Entry<String, Integer> entry : output.entrySet()) {
String path = entry.getKey();
int assignment = entry.getValue();
assertThat(assignment).isIn(Range.closed(0, shards));
counts[assignment + 1]++;
if (path.endsWith(CLASS_SUFFIX)) {
if (prev == -2) {
assertThat(assignment).isEqualTo(0);
} else if (prev > 0 && prev != assignment) {
assertThat(assignment).isEqualTo(prev + 1); // shard index increasing
if (expectPackageBoundaryShards) {
String prevDir = prevPath.substring(0, prevPath.lastIndexOf(ARCHIVE_DIR_SUFFIX));
String dir = path.substring(0, path.lastIndexOf(ARCHIVE_DIR_SUFFIX));
// package boundary, or full packages
assertThat(!prevDir.equals(dir) || counts[prev + 1] % packageSize != 0).isTrue();
}
}
prevPath = path;
}
prev = assignment;
}
return counts;
}
/**
* Validate that generated mapping maintains input order.
*/
private void assertMaintainOrder(Collection<String> input, Map<String, Integer> output) {
assertThat(output.keySet()).containsExactlyElementsIn(input).inOrder();
}
/**
* Verifies that packages have not been unnecessarily split.
*/
private void assertNoSplit(String name, int packageSize, int[] counts) {
for (int i = 1; i < counts.length; i++) {
assertWithMessage(name + " shard " + i).that(counts[i]).isAtLeast(0);
}
}
/**
* Verifies the presence of package-split in the tailing shards.
*/
private void assertHasSplit(String name, int packageSize, int[] counts) {
for (int i = 1; i < counts.length - 1; i++) {
if (counts[i + 1] <= 1) {
continue;
}
assertWithMessage(name + " shard " + i).that(counts[i]).isAtMost(packageSize);
}
}
/**
* Verify the presence of tailing empty shards, if unavoidable.
*/
private void assertHasEmpty(String name, int[] counts, boolean expectEmpty) {
boolean hasEmpty = false;
for (int i = 1; i < counts.length; i++) {
if (counts[i] == 0) {
hasEmpty = true;
} else {
assertThat(!hasEmpty || counts[i] == 0).isTrue();
}
}
assertWithMessage(name).that(hasEmpty).isEqualTo(expectEmpty);
}
/**
* Validates that each shard meets expected minimal and maximum size requirements,
* to ensure that shards are reasonably evenly sized.
*/
private void assertBalanced(String name, int shards, int packageCount, int packageSize,
int entries, int[] counts) {
int classes = packageSize * packageCount;
int noneClass = entries - counts[0] - classes;
int idealSize = Math.max(1, classes / shards);
int delta = Math.min(Math.min(10, (idealSize + 3) >> 2), (int) Math.log(shards));
int lowerBound = idealSize - delta;
int upperBound = idealSize + delta;
for (int i = 1; i < counts.length; i++) {
int adjusted = i == 1 ? counts[i] - noneClass : counts[i];
if (i < shards && counts[i + 1] > 1) {
if (shards <= packageCount) {
// if there are fewer shards than packages, expect shards contain at least 1 full package
assertWithMessage(name + " dense shard " + i)
.that(counts[i])
.isIn(Range.closed(packageSize, entries));
} else {
assertWithMessage(name + " sparse shard " + i)
.that(counts[i])
.isIn(Range.closed(0, packageSize));
}
if (noneClass == 0 && counts[0] == 0) {
// Give some slack in minimal number of entries in a shard because Splitter recomputes
// boundaries for each shard, so our computed bounds can be off for later shards.
assertWithMessage(name + " shard " + i)
.that(counts[i])
.isIn(Range.closed(lowerBound - i, entries));
}
}
// Give some slack in maximum number of entries in a shard because Splitter recomputes
// boundaries for each shard, so our computed bounds can be off for later shards.
assertWithMessage(name + " shard " + i).that(adjusted).isAtMost(upperBound + i);
}
}
/**
* Verifies that packages are only split as expected, and that no unexpected
* empty shards are generated.
*/
private void splitAsserts(String name, int shards, int packageCount, int packageSize,
int[] counts) {
boolean emptyExpected = packageCount * packageSize < shards;
boolean splitExpected = shards > packageCount;
if (splitExpected) {
assertHasSplit(name, packageSize, counts);
} else {
assertNoSplit(name, packageSize, counts);
}
assertHasEmpty(name, counts, emptyExpected);
}
/**
* Checks assert applicable to all tests.
*/
private int[] commonAsserts(String name, int shards, int packageCount, int packageSize,
Collection<String> input, Map<String, Integer> output) {
assertMaintainOrder(input, output);
int[] counts = assertAndCountMappings(shards, packageSize, output, packageCount <= shards);
assertBalanced(name, shards, packageCount, packageSize, input.size(), counts);
return counts;
}
}