private void onChunkOperationComplete(GetChunk chunk) {
    if (chunk.getChunkException() != null) {
      // if operation callback was already called, then this exception will have to be notified as part of the
      // read callback.
      setOperationException(chunk.getChunkException());
    }
    Exception e = getOperationException();
    if (chunk == firstChunk) {
      if (operationCallbackInvoked.compareAndSet(false, true)) {
        if (chunk.chunkBlobId.getBlobDataType() == BlobId.BlobDataType.METADATA) {
          /* Save metadata on successful completion. Everything is decrypted at this point. */
          saveMetadata();
          routerMetrics.getMetadataChunkLatencyMs.update(time.milliseconds() - chunk.initializedTimeMs);
        }
        if (options.getChunkIdsOnly) {
          // If this is an operation just to get the chunk ids, then these ids will be returned as part of the
          // result callback and no more chunks will be fetched, so mark the operation as complete to let the
          // GetManager remove this operation.
          setOperationCompleted();
          List<StoreKey> chunkIds = e == null && compositeBlobInfo != null ? compositeBlobInfo.getKeys() : null;
          operationResult = new GetBlobResult(blobInfo, null, chunkIds);
        } else {
          // Complete the operation from the caller's perspective, so that the caller can start reading from the
          // channel if there is no exception. The operation will not be marked as complete internally as subsequent
          // chunk retrievals and channel writes will need to happen and for that, this operation needs the GetManager to
          // poll it periodically. If any exception is encountered while processing subsequent chunks, those will be
          // notified during the channel read.
          long timeElapsed = time.milliseconds() - submissionTimeMs;
          if (isEncrypted) {
            routerMetrics.getEncryptedBlobOperationLatencyMs.update(timeElapsed);
          } else {
            routerMetrics.getBlobOperationLatencyMs.update(timeElapsed);
          }
          if (e == null) {
            if (blobInfo != null) {
              // For segmented blob we have already filtered out not-required chunks by now. So the first chunk is the segment we need.
              fixBlobSizeIfRequired(blobInfo.getBlobProperties().getBlobSize(),
                  options.getBlobOptions.hasBlobSegmentIdx() ? firstChunk.getChunkMetadataList().get(0).getSize()
                      : totalSize);
            }
            if (options.getBlobOptions.getOperationType() != GetBlobOptions.OperationType.BlobInfo) {
              blobDataChannel = new BlobDataReadableStreamChannel();
            } else {
              setOperationCompleted();
            }
            operationResult = new GetBlobResult(blobInfo, blobDataChannel, null);
          } else {
            blobDataChannel = null;
            operationResult = null;
            routerMetrics.onGetBlobError(e, options, isEncrypted);
          }
        }
        nonBlockingRouter.completeOperation(null, getOperationCallback, operationResult, e);
      }
    }
    chunk.postCompletionCleanup();
    if (blobDataChannel != null) {
      blobDataChannel.maybeWriteToChannel();
    }
  }
Do all that needs to be done (cleanup, notification, etc.) on chunk completion and mark the state of the chunk
appropriately.
@param chunk the chunk that has completed.
ambry-router/src/main/java/com/github/ambry/router/GetBlobOperation.java