Skip to content

Commit eb0f998

Browse files
committed
Fix logic issues during graceful shutdown
# Motivation This should fix the remaining issues raised in #166. The problem here was that if a service finished/threw out of order then we were wrongly treating this as if the service that we are currently shutting down finished. # Modification This PR ensures that we use the same `services` array during the graceful shutdown to nil out services that have finished. This way we correctly keep track of any service that finished. Additionally, there was a separate bug where we started to shutdown the next service to early if another service threw and had the termination behaviour of `shutdownGracefully`. # Result No more incorrect shutdown orderings.
1 parent b21c43a commit eb0f998

File tree

2 files changed

+295
-10
lines changed

2 files changed

+295
-10
lines changed

Sources/ServiceLifecycle/ServiceGroup.swift

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ public actor ServiceGroup: Sendable {
327327
services[index] = nil
328328
do {
329329
try await self.shutdownGracefully(
330-
services: services,
330+
services: &services,
331331
cancellationTimeoutTask: &cancellationTimeoutTask,
332332
group: &group,
333333
gracefulShutdownManagers: gracefulShutdownManagers
@@ -380,7 +380,7 @@ public actor ServiceGroup: Sendable {
380380

381381
do {
382382
try await self.shutdownGracefully(
383-
services: services,
383+
services: &services,
384384
cancellationTimeoutTask: &cancellationTimeoutTask,
385385
group: &group,
386386
gracefulShutdownManagers: gracefulShutdownManagers
@@ -421,7 +421,7 @@ public actor ServiceGroup: Sendable {
421421
)
422422
do {
423423
try await self.shutdownGracefully(
424-
services: services,
424+
services: &services,
425425
cancellationTimeoutTask: &cancellationTimeoutTask,
426426
group: &group,
427427
gracefulShutdownManagers: gracefulShutdownManagers
@@ -448,7 +448,7 @@ public actor ServiceGroup: Sendable {
448448

449449
do {
450450
try await self.shutdownGracefully(
451-
services: services,
451+
services: &services,
452452
cancellationTimeoutTask: &cancellationTimeoutTask,
453453
group: &group,
454454
gracefulShutdownManagers: gracefulShutdownManagers
@@ -489,7 +489,7 @@ public actor ServiceGroup: Sendable {
489489
}
490490

491491
private func shutdownGracefully(
492-
services: [ServiceGroupConfiguration.ServiceConfiguration?],
492+
services: inout [ServiceGroupConfiguration.ServiceConfiguration?],
493493
cancellationTimeoutTask: inout Task<Void, Never>?,
494494
group: inout ThrowingTaskGroup<ChildTaskResult, Error>,
495495
gracefulShutdownManagers: [GracefulShutdownManager]
@@ -519,7 +519,7 @@ public actor ServiceGroup: Sendable {
519519
self.logger.debug(
520520
"Service already finished. Skipping shutdown"
521521
)
522-
continue
522+
continue gracefulShutdownLoop
523523
}
524524
self.logger.debug(
525525
"Triggering graceful shutdown for service",
@@ -533,6 +533,7 @@ public actor ServiceGroup: Sendable {
533533
while let result = try await group.next() {
534534
switch result {
535535
case .serviceFinished(let service, let index):
536+
services[index] = nil
536537
if group.isCancelled {
537538
// The group is cancelled and we expect all services to finish
538539
continue gracefulShutdownLoop
@@ -561,7 +562,8 @@ public actor ServiceGroup: Sendable {
561562
throw ServiceGroupError.serviceFinishedUnexpectedly()
562563
}
563564

564-
case .serviceThrew(let service, _, let serviceError):
565+
case .serviceThrew(let service, let index, let serviceError):
566+
services[index] = nil
565567
switch service.failureTerminationBehavior.behavior {
566568
case .cancelGroup:
567569
self.logger.debug(
@@ -587,8 +589,15 @@ public actor ServiceGroup: Sendable {
587589
error = serviceError
588590
}
589591

590-
// We can continue shutting down the next service now
591-
continue gracefulShutdownLoop
592+
if index == gracefulShutdownIndex {
593+
// The service that we were shutting down right now threw. Since it's failure
594+
// behaviour is to shutdown the group we can continue
595+
continue gracefulShutdownLoop
596+
} else {
597+
// Another service threw while we were waiting for a shutdown
598+
// We have to continue the iterating the task group's result
599+
break
600+
}
592601

593602
case .ignore:
594603
self.logger.debug(

Tests/ServiceLifecycleTests/ServiceGroupTests.swift

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,8 @@ final class ServiceGroupTests: XCTestCase {
943943
// Let's throw from the second service
944944
await service2.resumeRunContinuation(with: .failure(ExampleError()))
945945

946-
// The first service should still be running
946+
// The first service should still be running but seeing a graceful shutdown signal firsts
947+
await XCTAsyncAssertEqual(await eventIterator1.next(), .shutdownGracefully)
947948
service1.sendPing()
948949
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
949950

@@ -1194,6 +1195,281 @@ final class ServiceGroupTests: XCTestCase {
11941195
}
11951196
}
11961197

1198+
1199+
func testTriggerGracefulShutdown_serviceThrows_inOrder_gracefullyShutdownGroup() async throws {
1200+
let service1 = MockService(description: "Service1")
1201+
let service2 = MockService(description: "Service2")
1202+
let service3 = MockService(description: "Service3")
1203+
let serviceGroup = self.makeServiceGroup(
1204+
services: [.init(service: service1, failureTerminationBehavior: .gracefullyShutdownGroup),
1205+
.init(service: service2, failureTerminationBehavior: .gracefullyShutdownGroup),
1206+
.init(service: service3, failureTerminationBehavior: .gracefullyShutdownGroup)]
1207+
)
1208+
1209+
do {
1210+
try await withThrowingTaskGroup(of: Void.self) { group in
1211+
group.addTask {
1212+
try await serviceGroup.run()
1213+
}
1214+
1215+
var eventIterator1 = service1.events.makeAsyncIterator()
1216+
await XCTAsyncAssertEqual(await eventIterator1.next(), .run)
1217+
1218+
var eventIterator2 = service2.events.makeAsyncIterator()
1219+
await XCTAsyncAssertEqual(await eventIterator2.next(), .run)
1220+
1221+
var eventIterator3 = service3.events.makeAsyncIterator()
1222+
await XCTAsyncAssertEqual(await eventIterator3.next(), .run)
1223+
1224+
await serviceGroup.triggerGracefulShutdown()
1225+
1226+
// The last service should receive the shutdown signal first
1227+
await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully)
1228+
1229+
// Waiting to see that all three are still running
1230+
service1.sendPing()
1231+
service2.sendPing()
1232+
service3.sendPing()
1233+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1234+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1235+
await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing)
1236+
1237+
// Let's exit from the last service
1238+
await service3.resumeRunContinuation(with: .success(()))
1239+
1240+
// The middle service should now receive the signal
1241+
await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully)
1242+
1243+
// Waiting to see that the two remaining are still running
1244+
service1.sendPing()
1245+
service2.sendPing()
1246+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1247+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1248+
1249+
// Let's exit from the second service
1250+
await service2.resumeRunContinuation(with: .failure(ExampleError()))
1251+
1252+
// The final service should now receive the signal
1253+
await XCTAsyncAssertEqual(await eventIterator1.next(), .shutdownGracefully)
1254+
1255+
// Waiting to see that the one remaining are still running
1256+
service1.sendPing()
1257+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1258+
1259+
// Let's exit from the first service
1260+
await service1.resumeRunContinuation(with: .success(()))
1261+
1262+
try await group.waitForAll()
1263+
}
1264+
1265+
XCTFail("Expected error not thrown")
1266+
} catch is ExampleError {
1267+
// expected error
1268+
}
1269+
}
1270+
1271+
func testTriggerGracefulShutdown_serviceThrows_inOrder_ignore() async throws {
1272+
let service1 = MockService(description: "Service1")
1273+
let service2 = MockService(description: "Service2")
1274+
let service3 = MockService(description: "Service3")
1275+
let serviceGroup = self.makeServiceGroup(
1276+
services: [.init(service: service1, failureTerminationBehavior: .ignore),
1277+
.init(service: service2, failureTerminationBehavior: .ignore),
1278+
.init(service: service3, failureTerminationBehavior: .ignore)]
1279+
)
1280+
1281+
try await withThrowingTaskGroup(of: Void.self) { group in
1282+
group.addTask {
1283+
try await serviceGroup.run()
1284+
}
1285+
1286+
var eventIterator1 = service1.events.makeAsyncIterator()
1287+
await XCTAsyncAssertEqual(await eventIterator1.next(), .run)
1288+
1289+
var eventIterator2 = service2.events.makeAsyncIterator()
1290+
await XCTAsyncAssertEqual(await eventIterator2.next(), .run)
1291+
1292+
var eventIterator3 = service3.events.makeAsyncIterator()
1293+
await XCTAsyncAssertEqual(await eventIterator3.next(), .run)
1294+
1295+
await serviceGroup.triggerGracefulShutdown()
1296+
1297+
// The last service should receive the shutdown signal first
1298+
await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully)
1299+
1300+
// Waiting to see that all three are still running
1301+
service1.sendPing()
1302+
service2.sendPing()
1303+
service3.sendPing()
1304+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1305+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1306+
await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing)
1307+
1308+
// Let's exit from the last service
1309+
await service3.resumeRunContinuation(with: .success(()))
1310+
1311+
// The middle service should now receive the signal
1312+
await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully)
1313+
1314+
// Waiting to see that the two remaining are still running
1315+
service1.sendPing()
1316+
service2.sendPing()
1317+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1318+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1319+
1320+
// Let's exit from the second service
1321+
await service2.resumeRunContinuation(with: .failure(ExampleError()))
1322+
1323+
// The final service should now receive the signal
1324+
await XCTAsyncAssertEqual(await eventIterator1.next(), .shutdownGracefully)
1325+
1326+
// Waiting to see that the one remaining are still running
1327+
service1.sendPing()
1328+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1329+
1330+
// Let's exit from the first service
1331+
await service1.resumeRunContinuation(with: .success(()))
1332+
1333+
try await group.waitForAll()
1334+
}
1335+
}
1336+
1337+
func testTriggerGracefulShutdown_serviceThrows_outOfOrder_gracefullyShutdownGroup() async throws {
1338+
let service1 = MockService(description: "Service1")
1339+
let service2 = MockService(description: "Service2")
1340+
let service3 = MockService(description: "Service3")
1341+
let serviceGroup = self.makeServiceGroup(
1342+
services: [.init(service: service1, failureTerminationBehavior: .gracefullyShutdownGroup),
1343+
.init(service: service2, failureTerminationBehavior: .gracefullyShutdownGroup),
1344+
.init(service: service3, failureTerminationBehavior: .gracefullyShutdownGroup)]
1345+
)
1346+
1347+
do {
1348+
try await withThrowingTaskGroup(of: Void.self) { group in
1349+
group.addTask {
1350+
try await serviceGroup.run()
1351+
}
1352+
1353+
var eventIterator1 = service1.events.makeAsyncIterator()
1354+
await XCTAsyncAssertEqual(await eventIterator1.next(), .run)
1355+
1356+
var eventIterator2 = service2.events.makeAsyncIterator()
1357+
await XCTAsyncAssertEqual(await eventIterator2.next(), .run)
1358+
1359+
var eventIterator3 = service3.events.makeAsyncIterator()
1360+
await XCTAsyncAssertEqual(await eventIterator3.next(), .run)
1361+
1362+
await serviceGroup.triggerGracefulShutdown()
1363+
1364+
// The last service should receive the shutdown signal first
1365+
await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully)
1366+
1367+
// Waiting to see that all three are still running
1368+
service1.sendPing()
1369+
service2.sendPing()
1370+
service3.sendPing()
1371+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1372+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1373+
await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing)
1374+
1375+
// Let's exit from the last service
1376+
await service3.resumeRunContinuation(with: .success(()))
1377+
1378+
// The middle service should now receive the signal
1379+
await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully)
1380+
1381+
// Waiting to see that the two remaining are still running
1382+
service1.sendPing()
1383+
service2.sendPing()
1384+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1385+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1386+
1387+
// Let's exit from the first service (even though the second service
1388+
// is gracefully shutting down)
1389+
await service1.resumeRunContinuation(with: .failure(ExampleError()))
1390+
1391+
// Waiting to see that the one remaining are still running
1392+
service2.sendPing()
1393+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1394+
1395+
// Let's exit from the second service
1396+
await service2.resumeRunContinuation(with: .success(()))
1397+
1398+
// The first service shutdown will be skipped
1399+
try await group.waitForAll()
1400+
}
1401+
1402+
XCTFail("Expected error not thrown")
1403+
} catch is ExampleError {
1404+
// expected error
1405+
}
1406+
}
1407+
1408+
func testTriggerGracefulShutdown_serviceThrows_outOfOrder_ignore() async throws {
1409+
let service1 = MockService(description: "Service1")
1410+
let service2 = MockService(description: "Service2")
1411+
let service3 = MockService(description: "Service3")
1412+
let serviceGroup = self.makeServiceGroup(
1413+
services: [.init(service: service1, failureTerminationBehavior: .ignore),
1414+
.init(service: service2, failureTerminationBehavior: .ignore),
1415+
.init(service: service3, failureTerminationBehavior: .ignore)]
1416+
)
1417+
1418+
try await withThrowingTaskGroup(of: Void.self) { group in
1419+
group.addTask {
1420+
try await serviceGroup.run()
1421+
}
1422+
1423+
var eventIterator1 = service1.events.makeAsyncIterator()
1424+
await XCTAsyncAssertEqual(await eventIterator1.next(), .run)
1425+
1426+
var eventIterator2 = service2.events.makeAsyncIterator()
1427+
await XCTAsyncAssertEqual(await eventIterator2.next(), .run)
1428+
1429+
var eventIterator3 = service3.events.makeAsyncIterator()
1430+
await XCTAsyncAssertEqual(await eventIterator3.next(), .run)
1431+
1432+
await serviceGroup.triggerGracefulShutdown()
1433+
1434+
// The last service should receive the shutdown signal first
1435+
await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully)
1436+
1437+
// Waiting to see that all three are still running
1438+
service1.sendPing()
1439+
service2.sendPing()
1440+
service3.sendPing()
1441+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1442+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1443+
await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing)
1444+
1445+
// Let's exit from the last service
1446+
await service3.resumeRunContinuation(with: .success(()))
1447+
1448+
// The middle service should now receive the signal
1449+
await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully)
1450+
1451+
// Waiting to see that the two remaining are still running
1452+
service1.sendPing()
1453+
service2.sendPing()
1454+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
1455+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1456+
1457+
// Let's exit from the first service (even though the second service
1458+
// is gracefully shutting down)
1459+
await service1.resumeRunContinuation(with: .failure(ExampleError()))
1460+
1461+
// Waiting to see that the one remaining are still running
1462+
service2.sendPing()
1463+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
1464+
1465+
// Let's exit from the second service
1466+
await service2.resumeRunContinuation(with: .success(()))
1467+
1468+
// The first service shutdown will be skipped
1469+
try await group.waitForAll()
1470+
}
1471+
}
1472+
11971473
// MARK: - Helpers
11981474

11991475
private func makeServiceGroup(

0 commit comments

Comments
 (0)