Skip to content
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

feat: Sharding #633

Merged
merged 3 commits into from
Feb 2, 2023
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ It is primarily responsible for image generation necessary for Visual Testing su
- [Multiple PNGs from 1 story](#multiple-pngs-from-1-story)
- [Basic usage](#basic-usage)
- [Variants composition](#variants-composition)
- [Parallelisation across multiple computers](#parallelisation-across-multiple-computers)
- [Tips](#tips)
- [Run with Docker](#run-with-docker)
- [Full control the screenshot timing](#full-control-the-screenshot-timing)
Expand Down Expand Up @@ -361,6 +362,9 @@ Options:
--verbose [boolean] [default: false]
--serverCmd Command line to launch Storybook server. [string] [default: ""]
--serverTimeout Timeout [msec] for starting Storybook server. [number] [default: 60000]
--shard The sharding options for this run. In the format <shardNumber>/<totalShards>.
<shardNumber> is a number between 1 and <totalShards>. <totalShards> is the total
number of computers working. [string] [default: "1/1"]
--captureTimeout Timeout [msec] for capture a story. [number] [default: 5000]
--captureMaxRetryCount Number of count to retry to capture. [number] [default: 3]
--metricsWatchRetryCount Number of count to retry until browser metrics stable. [number] [default: 1000]
Expand Down Expand Up @@ -455,6 +459,16 @@ The above example generates the following:

**Note:** You can extend some viewports with keys of `viewports` option because the `viewports` field is expanded to variants internally.

### Parallelisation across multiple computers

To process more stories in parallel across multiple computers, the `shard` argument can be used.

The `shard` argument is a string of the format: `<shardNumber>/<totalShards>`. `<shardNumber>` is a number between 1 and `<totalShards>`, inclusive. `<totalShards>` is the total number of computers running the execution.

For example, a run with `--shard 1/1` would be considered the default behaviour on a single computer. Two computers each running `--shard 1/2` and `--shard 2/2` respectively would split the stories across two computers.

Stories are distributed across shards in a round robin fashion when ordered by their ID. If a series of stories 'close together' are slower to screenshot than others, they should be distributed evenly.

## Tips

### Run with Docker
Expand Down
19 changes: 18 additions & 1 deletion packages/storycap/src/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import { time, ChromeChannel, getDeviceDescriptors } from 'storycrawler';
import { main } from './main';
import { MainOptions } from './types';
import { MainOptions, ShardOptions } from './types';
import yargs from 'yargs';
import { Logger } from './logger';
import { parseShardOptions } from './shard-utilities';

function showDevices(logger: Logger) {
getDeviceDescriptors().map(device => logger.log(device.name, JSON.stringify(device.viewport)));
Expand Down Expand Up @@ -41,6 +42,12 @@ function createOptions(): MainOptions {
default: 60_000,
description: 'Timeout [msec] for starting Storybook server.',
})
.option('shard', {
string: true,
default: '1/1',
description:
'The sharding options for this run. In the format <shardNumber>/<totalShards>. <shardNumber> is a number between 1 and <totalShards>. <totalShards> is the total number of computers working.',
})
.option('captureTimeout', { number: true, default: 5_000, description: 'Timeout [msec] for capture a story.' })
.option('captureMaxRetryCount', { number: true, default: 3, description: 'Number of count to retry to capture.' })
.option('metricsWatchRetryCount', {
Expand Down Expand Up @@ -109,6 +116,7 @@ function createOptions(): MainOptions {
verbose,
serverTimeout,
serverCmd,
shard,
captureTimeout,
captureMaxRetryCount,
metricsWatchRetryCount,
Expand Down Expand Up @@ -141,6 +149,14 @@ function createOptions(): MainOptions {
throw error;
}

let shardOptions: ShardOptions;
try {
shardOptions = parseShardOptions(shard);
} catch (error) {
logger.error(error);
throw error;
}

const opt = {
serverOptions: {
storybookUrl,
Expand All @@ -154,6 +170,7 @@ function createOptions(): MainOptions {
delay,
viewports: viewport,
parallel,
shard: shardOptions,
captureTimeout,
captureMaxRetryCount,
metricsWatchRetryCount,
Expand Down
25 changes: 23 additions & 2 deletions packages/storycap/src/node/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CapturingBrowser } from './capturing-browser';
import { MainOptions, RunMode } from './types';
import { FileSystem } from './file';
import { createScreenshotService } from './screenshot-service';
import { shardStories, sortStories } from './shard-utilities';

async function detectRunMode(storiesBrowser: StoriesBrowser, opt: MainOptions) {
// Reuse `storiesBrowser` instance to avoid cost of re-launching another Puppeteer process.
Expand Down Expand Up @@ -60,21 +61,41 @@ export async function main(mainOptions: MainOptions) {
storiesBrowser.close();

const stories = filterStories(allStories, mainOptions.include, mainOptions.exclude);

if (stories.length === 0) {
logger.warn('There is no matched story. Check your include/exclude options.');
return 0;
}

logger.log(`Found ${logger.color.green(stories.length + '')} stories.`);
const sortedStories = sortStories(stories);
const shardedStories = shardStories(sortedStories, mainOptions.shard.shardNumber, mainOptions.shard.totalShards);

if (shardedStories.length === 0) {
logger.log('This shard has no stories to screenshot.');
return 0;
}

if (mainOptions.shard.totalShards === 1) {
logger.log(`Found ${logger.color.green(String(stories.length))} stories.`);
} else {
logger.log(
`Found ${logger.color.green(String(stories.length))} stories. ${logger.color.green(
String(shardedStories.length),
)} are being processed by this shard (number ${mainOptions.shard.shardNumber} of ${
mainOptions.shard.totalShards
}).`,
);
}

// Launce Puppeteer processes to capture each story.
const { workers, closeWorkers } = await bootCapturingBrowserAsWorkers(connection, mainOptions, mode);
logger.debug('Created workers.');

try {
// Execution caputuring procedure.
return await createScreenshotService({ workers, stories, fileSystem, logger }).execute();
const captured = await createScreenshotService({ workers, stories: shardedStories, fileSystem, logger }).execute();
logger.debug('Ended ScreenshotService execution.');
return captured;
} catch (error) {
if (error instanceof ChromiumNotFoundError) {
throw new Error(
Expand Down
244 changes: 244 additions & 0 deletions packages/storycap/src/node/shard-utilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { Story } from 'storycrawler';
import { parseShardOptions, sortStories, shardStories } from './shard-utilities';

describe(parseShardOptions, () => {
it('should accept correct arguments', () => {
expect(parseShardOptions('1/1')).toMatchObject({ shardNumber: 1, totalShards: 1 });
expect(parseShardOptions('1/2')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions('2/2')).toMatchObject({ shardNumber: 2, totalShards: 2 });
expect(parseShardOptions('1/3')).toMatchObject({ shardNumber: 1, totalShards: 3 });
expect(parseShardOptions('2/3')).toMatchObject({ shardNumber: 2, totalShards: 3 });
expect(parseShardOptions('3/3')).toMatchObject({ shardNumber: 3, totalShards: 3 });
});
it('should be resiliant to whitespace', () => {
expect(parseShardOptions(' 1/2')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions('1/2 ')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions(' 1/2 ')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions('1 /2')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions('1/ 2')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions('1 / 2')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions(' 1 /2')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions('1/ 2 ')).toMatchObject({ shardNumber: 1, totalShards: 2 });
expect(parseShardOptions(' 1 / 2 ')).toMatchObject({ shardNumber: 1, totalShards: 2 });
});
it('errors for incorrect arguments', () => {
expect(() => parseShardOptions('0')).toThrowError();
expect(() => parseShardOptions('1')).toThrowError();
expect(() => parseShardOptions('text')).toThrowError();
expect(() => parseShardOptions('0/1')).toThrowError();
expect(() => parseShardOptions('-1/1')).toThrowError();
expect(() => parseShardOptions('2/1')).toThrowError();
expect(() => parseShardOptions('0/3')).toThrowError();
expect(() => parseShardOptions('/3')).toThrowError();
expect(() => parseShardOptions('4/')).toThrowError();
expect(() => parseShardOptions('4/3')).toThrowError();
expect(() => parseShardOptions('ab/c')).toThrowError();
});
});

describe(sortStories, () => {
it('should sort stories alphabetically based on their ID', () => {
const stories: Story[] = [
{
id: 'simple-tooltip--with-component-content',
kind: 'simple/Tooltip',
story: 'with component content',
version: 'v5',
},
{
id: 'complex-scene--for-table-of-contents',
kind: 'complex/Scene',
story: 'for table-of-contents',
version: 'v5',
},
{
id: 'complex-scene--basic-usage',
kind: 'complex/Scene',
story: 'basic-usage',
version: 'v5',
},
{
id: 'complex-scene--verticalannotation',
kind: 'complex/Scene',
story: 'verticalannotation',
version: 'v5',
},
];

const sortedStories = sortStories(stories);

let prev: Story | null = null;

for (const next of sortedStories) {
if (!prev) {
prev = next;
continue;
}
expect(next.id > prev.id).toBeTruthy();

prev = next;
}
});
});

describe(shardStories, () => {
it('a single shard gets all the stories', () => {
const stories: Story[] = [
{
id: 'simple-tooltip--with-component-content',
kind: 'simple/Tooltip',
story: 'with component content',
version: 'v5',
},
{
id: 'complex-scene--for-table-of-contents',
kind: 'complex/Scene',
story: 'for table-of-contents',
version: 'v5',
},
{
id: 'complex-scene--basic-usage',
kind: 'complex/Scene',
story: 'basic-usage',
version: 'v5',
},
{
id: 'complex-scene--verticalannotation',
kind: 'complex/Scene',
story: 'verticalannotation',
version: 'v5',
},
];

const sortedStories = sortStories(stories);
const shardedStories = shardStories(sortedStories, 1, 1);

expect(shardedStories).toMatchObject(sortedStories);
});
it('two shards get equal amounts of stories when the number of them is even', () => {
const stories: Story[] = [
{
id: 'simple-tooltip--with-component-content',
kind: 'simple/Tooltip',
story: 'with component content',
version: 'v5',
},
{
id: 'complex-scene--for-table-of-contents',
kind: 'complex/Scene',
story: 'for table-of-contents',
version: 'v5',
},
{
id: 'complex-scene--basic-usage',
kind: 'complex/Scene',
story: 'basic-usage',
version: 'v5',
},
{
id: 'complex-scene--verticalannotation',
kind: 'complex/Scene',
story: 'verticalannotation',
version: 'v5',
},
];

const sortedStories = sortStories(stories);
const shardedStoriesA = shardStories(sortedStories, 1, 2);
const shardedStoriesB = shardStories(sortedStories, 2, 2);

expect(shardedStoriesA.length).toBe(shardedStoriesB.length);
});

it('two shards get roughly equal amounts of stories when the number of them is odd', () => {
const stories: Story[] = [
{
id: 'simple-tooltip--with-component-content',
kind: 'simple/Tooltip',
story: 'with component content',
version: 'v5',
},
{
id: 'complex-scene--for-table-of-contents',
kind: 'complex/Scene',
story: 'for table-of-contents',
version: 'v5',
},
{
id: 'complex-scene--verticalannotation',
kind: 'complex/Scene',
story: 'verticalannotation',
version: 'v5',
},
];

const sortedStories = sortStories(stories);
const shardedStoriesA = shardStories(sortedStories, 1, 2);
const shardedStoriesB = shardStories(sortedStories, 2, 2);

expect(Math.abs(shardedStoriesA.length - shardedStoriesB.length)).toBeLessThanOrEqual(1);
});

it("stories aren't duplicated when there are more shards than stories", () => {
const stories: Story[] = [
{
id: 'simple-tooltip--with-component-content',
kind: 'simple/Tooltip',
story: 'with component content',
version: 'v5',
},
{
id: 'complex-scene--for-table-of-contents',
kind: 'complex/Scene',
story: 'for table-of-contents',
version: 'v5',
},
];

const sortedStories = sortStories(stories);
const shardedStoriesA = shardStories(sortedStories, 1, 4);
const shardedStoriesB = shardStories(sortedStories, 2, 4);
const shardedStoriesC = shardStories(sortedStories, 3, 4);
const shardedStoriesD = shardStories(sortedStories, 4, 4);

expect(shardedStoriesA.length + shardedStoriesB.length + shardedStoriesC.length + shardedStoriesD.length).toBe(
sortedStories.length,
);
});

it('complex and simple stories are distributed evenly across shards', () => {
function makeDummyStory(index: number, complex: boolean): Story {
return {
id: `${complex ? 'complex' : 'simple'}-component--${index}`,
kind: `${complex ? 'complex' : 'simple'}/Component`,
story: `${index}`,
version: 'v5',
} as const;
}

const stories: Story[] = [
makeDummyStory(0, true),
makeDummyStory(1, true),
makeDummyStory(2, true),
makeDummyStory(3, true),
makeDummyStory(4, false),
makeDummyStory(5, false),
makeDummyStory(6, false),
makeDummyStory(7, false),
makeDummyStory(8, false),
makeDummyStory(9, false),
makeDummyStory(10, false),
makeDummyStory(11, false),
];

const sortedStories = sortStories(stories);
const shardedStoriesA = shardStories(sortedStories, 1, 2);
const shardedStoriesB = shardStories(sortedStories, 2, 2);

const numComplexOnA = shardedStoriesA.filter(story => story.id.startsWith('complex')).length;
const numComplexOnB = shardedStoriesB.filter(story => story.id.startsWith('complex')).length;

expect(shardedStoriesA.length).toBe(shardedStoriesB.length);
expect(numComplexOnA).toBe(numComplexOnB);
});
});
Loading