I have a chain of many NSBlockOperation
s with dependencies. If one operation early in the chain fails - I want the other operations to not run. According to docs, this should be easy to do from the outside - if I cancel an operation, all dependent operations should automatically be cancelled.
However - if only the execution-block of my operation "knows" that it failed, while executing - can it cancel
its own work?
I tried the following:
NSBlockOperation *op = [[NSBlockOperation alloc] init];
__weak NSBlockOperation *weakOpRef = op;
[takeScreenShot addExecutionBlock:^{
LOGInfo(@"Say Cheese...");
if (some_condition == NO) { // for some reason we can't take a photo
[weakOpRef cancel];
LOGError(@"Photo failed");
}
else {
// take photo, process it, etc.
LOGInfo(@"Photo taken");
}
}];
However, when I run this, other operations dependent on op
are executed even though op
was cancelled. Since they are dependent - surely they're not starting before op
finished, and I verified (in debugger and using logs) that isCancelled
state of op
is YES
before the block returns. Still the queue executes them as if op
finished successfully.
I then further challenged the docs, like thus:
NSOperationQueue *myQueue = [[NSOperationQueue alloc] init];
NSBlockOperation *op = [[NSBlockOperation alloc] init];
__weak NSBlockOperation *weakOpRef = takeScreenShot;
[takeScreenShot addExecutionBlock:^{
NSLog(@"Say Cheese...");
if (weakOpRef.isCancelled) { // Fail every once in a while...
NSLog(@"Photo failed");
}
else {
[NSThread sleepForTimeInterval:0.3f];
NSLog(@"Photo taken");
}
}];
NSOperation *processPhoto = [NSBlockOperation blockOperationWithBlock:^{
NSLog(@"Processing Photo...");
[NSThread sleepForTimeInterval:0.1f]; // Process
NSLog(@"Processing Finished.");
}];
// setup dependencies for the operations.
[processPhoto addDependency: op];
[op cancel]; // cancelled even before dispatching!!!
[myQueue addOperation: op];
[myQueue addOperation: processPhoto];
NSLog(@">>> Operations Dispatched, Wait for processing");
[eventQueue waitUntilAllOperationsAreFinished];
NSLog(@">>> Work Finished");
But was horrified to see the following output in the log:
2020-11-05 16:18:03.803341 >>> Operations Dispatched, Wait for processing
2020-11-05 16:18:03.803427 Processing Photo...
2020-11-05 16:18:03.813557 Processing Finished.
2020-11-05 16:18:03.813638+0200 TesterApp[6887:111445] >>> Work Finished
Pay attention: the cancelled op was never run - but the dependent processPhoto
was executed, despite its dependency on op
.
Ideas anyone?
OK. I think I solved the mystery. I just misunderstood the [NSOperation cancel]
documentation.
it says:
In macOS 10.6 and later, if an operation is in a queue but waiting on unfinished dependent operations, those operations are subsequently ignored. Because it is already cancelled, this behavior allows the operation queue to call the operation’s start method sooner and clear the object out of the queue. If you cancel an operation that is not in a queue, this method immediately marks the object as finished. In each case, marking the object as ready or finished results in the generation of the appropriate KVO notifications.
I thought if operation B depends on operation A - it implies that if A is canceled (hence - A didn't finish its work) then B should be cancelled as well, because semantically it can't start until A completes its work.
Apparently, that was just wishful thinking...
What documentation says is different. When you cancel operation B (which depends on operation A), then despite being dependent on A - it won't wait for A to finish before it's removed from the queue. If operation A started, but hasn't finished yet - canceling B will remove it (B) immediately from the queue - because it will now ignore dependencies (the completion of A).
Soooo... to accomplish my scheme, I will need to introduce my own "dependencies" mechanism. The straightforward way is by introducing a set of boolean properties like isPhotoTaken
, isPhotoProcessed
, isPhotoColorAnalyzed
etc. Then, an operation dependent on these pre-processing actions, will need to check in its preamble (of execution block) whether all required previous operations actually finished successfully, else cancel itself.
However, it may be worth subclassing NSBlockOperation, overriding the logic that calls 'start' to skip to finished if any of the 'dependencies' has been cancelled!
Initially I thought this is a long shot and may be hard to implement, but fortunately, I wrote this quick subclass, and it seems to work fine. Of course deeper inspection and stress tests are due:
@interface MYBlockOperation : NSBlockOperation {
}
@end
@implementation MYBlockOperation
- (void)start {
if ([[self valueForKeyPath:@"dependencies.@sum.cancelled"] intValue] > 0)
[self cancel];
[super start];
}
@end
When I substitute NSBlockOperation with MYBlockOperation in the original question (and my other tests, the behaviour is the one I described and expected.