Skip to content

Commit 6277c2b

Browse files
authored
Merge pull request #68 from openhie/CU-86by2x2u5-concurrent-package-actions
Allow concurrent actions on packages
2 parents f1228be + 289e1cf commit 6277c2b

File tree

12 files changed

+3231
-662
lines changed

12 files changed

+3231
-662
lines changed

.github/workflows/test-instant.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Run Instant Tests
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
9+
jobs:
10+
instant-tests:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout code
14+
uses: actions/checkout@v3
15+
16+
- name: Setup Node.js
17+
uses: actions/setup-node@v3
18+
with:
19+
node-version: '20'
20+
21+
- name: Install dependencies
22+
run: yarn install
23+
24+
- name: Run Tests
25+
run: yarn test

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,7 @@ ADD instant.ts .
3434
# add util function scripts
3535
ADD utils ./utils
3636

37+
# add schema
38+
ADD schema ./schema
39+
3740
ENTRYPOINT [ "yarn", "instant" ]

babel.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
presets: [
3+
['@babel/preset-env', { targets: { node: 'current' } }],
4+
'@babel/preset-typescript'
5+
]
6+
}

cli/src/cmd/flags/flags.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,5 @@ func setCommonActionFlags(cmd *cobra.Command) {
4040
flags.StringSliceVar(&state.EnvFiles, "env-file", nil, "env file")
4141
flags.StringVar(&state.ConfigFile, "config", "", "config file (default is $WORKING_DIR/config.yaml)")
4242
flags.StringSliceP("env-var", "e", nil, "Env var(s) to set or overwrite")
43+
flags.StringP("concurrency", "", "", "The concurrency level to use for executing actions on packages (default 5)")
4344
}

cli/src/core/parse/command.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ func GetInstantCommand(packageSpec core.PackageSpec) []string {
2626
instantCommand = append(instantCommand, "--only")
2727
}
2828

29+
if packageSpec.Concurrency != "" {
30+
instantCommand = append(instantCommand, "--concurrency", packageSpec.Concurrency)
31+
}
32+
2933
instantCommand = append(instantCommand, packageSpec.Packages...)
3034

3135
for _, customPackage := range packageSpec.CustomPackages {

cli/src/core/parse/packageSpec.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ func getPackageSpecFromParams(cmd *cobra.Command, config *core.Config) (*core.Pa
5353
if err != nil {
5454
return nil, errors.Wrap(err, "")
5555
}
56+
concurrency, err := cmd.Flags().GetString("concurrency")
57+
if err != nil {
58+
return nil, errors.Wrap(err, "")
59+
}
5660

5761
var envVariables []string
5862
if cmd.Flags().Changed("env-file") {
@@ -77,6 +81,7 @@ func getPackageSpecFromParams(cmd *cobra.Command, config *core.Config) (*core.Pa
7781
IsDev: isDev,
7882
IsOnly: isOnly,
7983
DeployCommand: cmd.Use,
84+
Concurrency: concurrency,
8085
}
8186

8287
return &packageSpec, nil

cli/src/core/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type PackageSpec struct {
3232
CustomPackages []CustomPackage
3333
ImageVersion string
3434
TargetLauncher string
35+
Concurrency string
3536
}
3637

3738
type GeneratePackageSpec struct {

instant.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
2+
3+
import {
4+
createDependencyTree,
5+
walkDependencyTree,
6+
concurrentifyAction
7+
} from './instant'
8+
9+
describe('createDependencyTree', () => {
10+
it('should handle a single package with no dependencies', () => {
11+
const allPackages = {
12+
package1: { metadata: { id: 'package1', dependencies: [] } }
13+
}
14+
const chosenPackageIds = ['package1']
15+
const expectedTree = { package1: {} }
16+
expect(createDependencyTree(allPackages, chosenPackageIds)).toEqual(
17+
expectedTree
18+
)
19+
})
20+
21+
it('should handle multiple packages with dependencies', () => {
22+
const allPackages = {
23+
package1: { metadata: { id: 'package1', dependencies: ['package2'] } },
24+
package2: { metadata: { id: 'package2', dependencies: [] } }
25+
}
26+
const chosenPackageIds = ['package1']
27+
const expectedTree = { package1: { package2: {} } }
28+
expect(createDependencyTree(allPackages, chosenPackageIds)).toEqual(
29+
expectedTree
30+
)
31+
})
32+
33+
it('should handle complex dependency trees', () => {
34+
const allPackages = {
35+
package1: {
36+
metadata: { id: 'package1', dependencies: ['package2', 'package3'] }
37+
},
38+
package2: { metadata: { id: 'package2', dependencies: ['package3'] } },
39+
package3: { metadata: { id: 'package3', dependencies: [] } }
40+
}
41+
const chosenPackageIds = ['package1']
42+
const expectedTree = {
43+
package1: { package2: { package3: {} }, package3: {} }
44+
}
45+
expect(createDependencyTree(allPackages, chosenPackageIds)).toEqual(
46+
expectedTree
47+
)
48+
})
49+
50+
it('should throw an error for circular dependencies', () => {
51+
const allPackages = {
52+
package1: { metadata: { id: 'package1', dependencies: ['package2'] } },
53+
package2: { metadata: { id: 'package2', dependencies: ['package1'] } }
54+
}
55+
const chosenPackageIds = ['package1']
56+
expect(() => createDependencyTree(allPackages, chosenPackageIds)).toThrow(
57+
'Circular dependency detected: package1 has already been visited.'
58+
)
59+
})
60+
61+
it('should handle invalid or missing package IDs gracefully', () => {
62+
const allPackages = {
63+
package1: { metadata: { id: 'package1', dependencies: [] } }
64+
}
65+
const chosenPackageIds = ['nonExistentPackage']
66+
expect(() => createDependencyTree(allPackages, chosenPackageIds)).toThrow(
67+
'Invalid package ID: nonExistentPackage'
68+
)
69+
})
70+
})
71+
72+
describe('walkDependencyTree', () => {
73+
const mockAction = jest.fn()
74+
75+
beforeEach(() => {
76+
mockAction.mockClear()
77+
})
78+
79+
it('should call action on a single node tree in pre-order', async () => {
80+
const tree = { package1: {} }
81+
await walkDependencyTree(tree, 'pre', mockAction)
82+
expect(mockAction).toHaveBeenCalledTimes(1)
83+
expect(mockAction).toHaveBeenCalledWith('package1')
84+
})
85+
86+
it('should call action on a single node tree in post-order', async () => {
87+
const tree = { package1: {} }
88+
await walkDependencyTree(tree, 'post', mockAction)
89+
expect(mockAction).toHaveBeenCalledTimes(1)
90+
expect(mockAction).toHaveBeenCalledWith('package1')
91+
})
92+
93+
it('should walk a complex tree in pre-order and call action correctly', async () => {
94+
const tree = {
95+
package1: {
96+
package2: {},
97+
package3: {
98+
package4: {}
99+
}
100+
}
101+
}
102+
await walkDependencyTree(tree, 'pre', mockAction)
103+
expect(mockAction.mock.calls).toEqual([
104+
['package1'],
105+
['package2'],
106+
['package3'],
107+
['package4']
108+
])
109+
})
110+
111+
it('should walk a complex tree in post-order and call action correctly', async () => {
112+
const tree = {
113+
package1: {
114+
package2: {},
115+
package3: {
116+
package4: {}
117+
}
118+
}
119+
}
120+
await walkDependencyTree(tree, 'post', mockAction)
121+
expect(mockAction.mock.calls).toEqual([
122+
['package2'],
123+
['package4'],
124+
['package3'],
125+
['package1']
126+
])
127+
})
128+
129+
it('should handle an empty tree', async () => {
130+
const tree = {}
131+
await walkDependencyTree(tree, 'pre', mockAction)
132+
expect(mockAction).not.toHaveBeenCalled()
133+
})
134+
})
135+
136+
describe('concurrentifyAction', () => {
137+
it('executes actions concurrently up to the specified limit', async () => {
138+
const action = jest
139+
.fn()
140+
.mockImplementation(
141+
(id) => new Promise((resolve) => setTimeout(resolve, 100))
142+
)
143+
const concurrentAction = concurrentifyAction(action, 2)
144+
145+
const startTime = Date.now()
146+
await Promise.all([
147+
concurrentAction('1'),
148+
concurrentAction('2'),
149+
concurrentAction('3') // This should wait until one of the first two completes
150+
])
151+
const endTime = Date.now()
152+
153+
expect(action).toHaveBeenCalledTimes(3)
154+
// Check if the total time taken is in the expected range considering concurrency limit
155+
expect(endTime - startTime).toBeGreaterThanOrEqual(200) // At least two batches of 100ms each
156+
})
157+
158+
it('does not execute the same action for a given ID more than once', async () => {
159+
const action = jest.fn().mockResolvedValue(undefined)
160+
const concurrentAction = concurrentifyAction(action, 2)
161+
162+
await Promise.all([
163+
concurrentAction('1'),
164+
concurrentAction('1') // This should not cause a second execution
165+
])
166+
167+
expect(action).toHaveBeenCalledTimes(1)
168+
})
169+
170+
it('queues actions correctly when exceeding the concurrency limit', async () => {
171+
let activeCount = 0
172+
const action = jest.fn().mockImplementation(async (id) => {
173+
activeCount++
174+
expect(activeCount).toBeLessThanOrEqual(2) // Ensure no more than 2 active at a time
175+
await new Promise((resolve) => setTimeout(resolve, 50))
176+
activeCount--
177+
})
178+
const concurrentAction = concurrentifyAction(action, 2)
179+
180+
await Promise.all([
181+
concurrentAction('1'),
182+
concurrentAction('2'),
183+
concurrentAction('3'),
184+
concurrentAction('4') // These should be queued
185+
])
186+
187+
expect(action).toHaveBeenCalledTimes(4)
188+
})
189+
})

0 commit comments

Comments
 (0)