Skip to content

Add extension points for pluggable task caching and object-storage access - #7638

Open
jorgee wants to merge 4 commits into
masterfrom
globalcache-core-spi
Open

jorgee wants to merge 4 commits into
masterfrom
globalcache-core-spi

Conversation

@jorgee

@jorgee jorgee commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

This adds four extension points and their cloud implementations. Every default preserves current behaviour exactly — with no extension registered, the code takes the path it takes today.

nextflow.processor — task hashing and cache resolution

TaskHasherFactory creates the TaskHasher for a task, or abstains. With none registered, or all abstaining, TaskProcessor does new TaskHasher(task) as before; core's TaskHasher is untouched.

TaskCacheStrategy decides how a task is resolved against the cache, through the TaskResolver primitives TaskProcessor implements. DefaultTaskCacheStrategy is today's checkCachedOrLaunchTask loop moved verbatim — the tries fold, the lock, mkdirs, submitTask — and checkCachedOrLaunchTask becomes the dispatch that picks it.

nf-commons — object-storage capabilities

ObjectStoreReader adds a flat, no-delimiter listing returning each object's size and mtime, and a ranged read of a single byte window. The listing costs O(N/1000) calls regardless of nesting depth, where the NIO walk costs one delimiter-based LIST per subdirectory. Both methods default to null, so a scheme without a provider falls back to the hierarchical walk and the full read.

AtomicLockProvider adds create-if-absent of a marker object, answered by the store rather than by a read-then-write. Object storage has no directory-create to race on, so this is the portable way for two processes on different machines to agree on which owns a prefix.

Implemented for S3 (ifNoneMatch("*")), Azure Blob (setIfNoneMatch('*'), matched on the error code rather than the status, with the six responses that mean "taken" driven from a table) and GCS (doesNotExist()). None is a read-then-write.

Supporting changes

CacheFactory may resolve its own work directory and write it back to the session, so Session.init creates the cache before reading workDir — everything below it must see the effective value, or workflow.workDir names a directory no task uses. A throw after that point now closes the cache instead of leaking it.

CacheStore.updateEntry (a default method) lets a store composed of several members route an update back to the member that served the read. CacheDB.dispatchWrite is an internal refactor so a failed async cache write is logged with its cause rather than swallowed.

FileHelper.getTaskHashFromPath tolerates an optional -N suffix on the work-directory leaf. nf-lineage resolves every task input to its producing task by parsing that path (LinObserver.manageFileInParamgetSourceReference). A cache that lays the attempts of one task out side by side keeps the two-level <2hex>/<30hex> hierarchy and appends the attempt number to the leaf; without this tolerance the parser returns null for those inputs, and lineage records them as bare DataPath entries — losing the edge to the producing task and paying a content checksum in its place. The parser is already lenient by design (a non-2-char bucket and an unparseable hash both return null), so this is one more accepted leaf shape, and hex never contains a dash, so it stays unambiguous.

@netlify

netlify Bot commented Sep 18, 2026

Copy link
Copy Markdown

Deploy Preview for nextflow-docs canceled.

Name Link
🔨 Latest commit 7b12209
🔍 Latest deploy log https://app.netlify.com/projects/nextflow-docs/deploys/6aad23f5c6f93200081fad23

Two extension points in nf-commons, both scheme-keyed and both optional:

- ObjectStoreReader: a flat, no-delimiter listing that returns each object's
  size and mtime, and a ranged read of a single byte window. The listing is
  O(N/1000) calls regardless of how deeply the tree nests, where the NIO walk
  costs one delimiter-based LIST per subdirectory; the ranged read fetches one
  window instead of the whole object. Both default to null -- not implemented --
  so a caller falls back to the hierarchical walk and the full read.

- AtomicLockProvider: create-if-absent of a marker object, answered by the
  store itself rather than by a read-then-write. Object storage has no
  directory-create to race on, so this is the only portable way for two
  processes on different machines to agree on which of them owns a prefix.

ObjectMeta carries the (size, mtime) pair the listing returns.

Neither SPI has an implementation in this commit, so nothing changes: a scheme
with no provider takes exactly the path it takes today.

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: jorgee <jorge.ejarque@seqera.io>
Each provider gets a flat listing with per-object (size, mtime), a ranged read,
and a conditional create. The conditional create is the part that has to be
exact, because it is what two processes on different machines rely on to agree:

- S3    ifNoneMatch("*")     -> 412 when the object exists
- Azure setIfNoneMatch('*')  -> matched on the error CODE, not the status, with
                                a table of the six responses that mean 'taken'
- GCS   doesNotExist()       -> 412 via ifGenerationMatch=0

None is a read-then-write, so none has a window between the check and the create.

The ranged reads bound both the request and the buffer where the SDK allows it:
on GCS limit() alone still allocates the 2 MiB default chunk locally, while
setChunkSize() alone leaves the request open to the end of the object -- either
one by itself leaves half the waste.

GsStorageOptions holds the shared GCS client so the two extensions resolve one
client per JVM rather than one each, and the session config lookup they both
need.

S3Client gains putObjectIfAbsent, which carries the same ACL/KMS/SSE settings
its sibling writes apply: a bucket policy of the 'deny unless encrypted' kind
would otherwise reject the create with a 403, which the caller cannot read as
'lost the race'.

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: jorgee <jorge.ejarque@seqera.io>
Two extension points in nextflow.processor, both with defaults that preserve
today's behaviour exactly:

- TaskHasherFactory creates the TaskHasher for a task, or abstains. With no
  extension registered, or all abstaining, TaskProcessor does `new
  TaskHasher(task)` as before. Core's TaskHasher is untouched.

- TaskCacheStrategy decides how a task is resolved against the cache, through
  the TaskResolver primitives the processor implements. DefaultTaskCacheStrategy
  is master's checkCachedOrLaunchTask loop moved verbatim -- the tries fold, the
  lock, mkdirs, submitTask -- so a run with no extension takes the same path it
  takes today. checkCachedOrLaunchTask becomes the dispatch.

Supporting changes:

- CacheFactory may resolve its own work directory and write it back to the
  session, so Session.init creates the cache before reading workDir. Everything
  below -- the work dir creation, the observers, the WorkflowMetadata snapshot --
  must see the effective value, or workflow.workDir names a directory no task
  uses. A throw after that point now closes the cache rather than leaking it.

- CacheStore.updateEntry, a default method, so a store composed of several
  members can route an update back to the member that served the read.

- CacheDB.dispatchWrite, an internal refactor so a failed async cache write is
  logged with its cause instead of being swallowed.

- FileHelper.getTaskHashFromPath tolerates an optional `-N` suffix on the work
  directory leaf. nf-lineage resolves every task input to its producing task by
  parsing that path (LinObserver.manageFileInParam -> getSourceReference); a
  cache that lays the attempts of one task out side by side keeps the two-level
  <2hex>/<30hex> hierarchy and appends the attempt to the leaf. Without this the
  parser returns null for those inputs and lineage records them as bare
  DataPaths, losing the edge to the producing task and paying a content checksum
  in its place. The parser is already lenient by design -- a non-2-char bucket
  and an unparseable hash both return null -- so this is one more accepted leaf
  shape, and hex never contains a dash, so it stays unambiguous.

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: jorgee <jorge.ejarque@seqera.io>
@jorgee
jorgee force-pushed the globalcache-core-spi branch from 98dd386 to 671d20d Compare September 18, 2026 11:25
It required NXF_GCS_TEST_BUCKET and GOOGLE_PROJECT_ID, neither of which is set
anywhere, so it never ran. AwsS3BaseSpec and AzBaseSpec create and delete their
own bucket from credentials alone; this does the same, and takes the project
from the credentials too -- GoogleOpts.getProjectIdFromCreds reads project_id
out of the service-account JSON that GOOGLE_APPLICATION_CREDENTIALS points at.
So the gate is now credentials alone, matching BatchLoggingTest.

A user ADC from `gcloud auth application-default login` carries no project_id,
and getProjectIdFromCreds throws rather than returning null for one that does
not, so that case is caught and skipped: a developer without a service-account
key is not stopped by a test that can still run where such a key exists.

Assisted-by: Claude Opus 5 (1M context)
Signed-off-by: jorgee <jorge.ejarque@seqera.io>
@jorgee
jorgee marked this pull request as ready for review September 18, 2026 11:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant