Missing arguments

August 17, 2021 ยท View on GitHub

A workflow with missing call arguments is legal in WDL. For example, workflow trivial calls task add with parameter a where parameters a and b are required. If the user supplies b at runtime, the workflow will execute correctly, otherwise, a missing argument error will result.

workflow trivial {
    Int x
    call add {
      input: a=x
    }
    output {
        Int sum = add.result
    }
}

task add {
  Int a
  Int b
  command {}
  output {
    Int result = a + b
  }
}

The JSON inputs file below specifies b:

{
  "trivial.x" : 1,
  "trivial.add.b" : 3
}

In effect, missing call arguments are inputs to the workflow. This also works for optional arguments. For example, if add has optional argument c, then, it is possible to set it from a file:

{
  "trivial.x" : 1,
  "trivial.add.b" : 3,
  "trivial.add.c" : 10
}

This means that workflow trivial actually has four inputs: {x, add.a, add.b, add.c}. A large realistic workflow makes many calls, and has many hidden arguments. To implement this with a DNAnexus workflow, we need to materialize all these inputs, they cannot remain hidden. The resulting platform workflow could easily have tens or hundreds of inputs, making the user interface ungainly.

Implementation issues

An implementation will need to start by adding the missing arguments. Let's take a look at adding trivial.add.b. The workflow is rewritten to:

workflow trivial {
    Int x
    Int add_b
    call add {
      input: a=x, b=add_b
    }
    output {
        Int sum = add.result
    }
}

When reading the input file, we need to translate trivial.add.b to trivial.add_b. This requires a mapping from workflow input arguments to their original names. This is extra metadata for the compilation process; it is not simply just additional WDL code.

new nameoriginal
trivial.xtrivial.x
trivial.add_btrivial.add.b

If a variable named add_b already exists, a new name is required for add.b. Each workflow can go through multiple rewrite steps, each of which may encounter naming collisions. For a complex workflow, the end result could be so different from the original, as to be unrecognizable. Because names are mangled, following what happens at runtime in the UI will be hard.

Compromise: toplevel calls compiled as stages

There is an important use case where leaving task arguments unbound is desirable. The detect_virus workflow below, is a simplified version of the real world worklfow assemble_denovo_with_deplete_and_isnv_calling. The three tasks have a large number of inputs. If we propage them to the workflow level, we will have an unreadable script, and potential argument name collisions. Users would like to be able to do:

dx run detect_virus -ideplete_taxa.query_chunk_size=200

This makes it clear that query_chunk_size is an input for the deplete_taxa call, and not to any other call.

workflow detect_virus {
  File raw_reads_unmapped_bam

  call deplete_taxa {
    input:
      raw_reads_unmapped_bam = raw_reads_unmapped_bam,
      bmtaggerDbs = bmtaggerDbs,
      blastDbs = blastDbs,
      bwaDbs = bwaDbs
  }

  call filter_to_taxon {
    input:
      reads_unmapped_bam = deplete_taxa.cleaned_bam
  }

  call assembly.assemble {
   input:
     reads_unmapped_bam = filter_to_taxon.taxfilt_bam,
     trim_clip_db = trim_clip_db
  }

  ...
}

task deplete_taxa {
  File         raw_reads_unmapped_bam
  Array[File]? bmtaggerDbs  # .tar.gz, .tgz, .tar.bz2, .tar.lz4, .fasta, or .fasta.gz
  Array[File]? blastDbs  # .tar.gz, .tgz, .tar.bz2, .tar.lz4, .fasta, or .fasta.gz
  Array[File]? bwaDbs  # .tar.gz, .tgz, .tar.bz2, .tar.lz4, .fasta, or .fasta.gz
  Int?         query_chunk_size
  Boolean?     clear_tags = false
  String? tags_to_clear_space_separated = "XT X0 X1 XA AM SM BQ CT XN OC OP"

  ...
}

task filter_to_taxon {
  File reads_unmapped_bam
  File lastal_db_fasta
  String bam_basename = basename(basename(reads_unmapped_bam, ".bam"), ".cleaned")
  ...
}

task assemble {
  File    reads_unmapped_bam
  File    trim_clip_db
  Int?    trinity_n_reads=250000
  Int?    spades_n_reads=10000000
  String? assembler="trinity"  # trinity, spades, or trinity-spades
  String  cleaned_assembler = select_first([assembler, ""])
  String  sample_name = basename(basename(reads_unmapped_bam, ".bam"), ".taxfilt")
  ...
}

To support this use case, if detect_virus is compiled to an unlocked workflow, the compiler generates a dx:workflow with a stage for the deplete_taxa, filter_to_taxon, and assemble calls. More generally, any toplevel call with no subexpressions will be compiled to a stage. For example, in workflow foo, only call C fits the bill.


workflow foo {
  String who

  if (flag) {
     call A
  }
  scatter (i in [1,2,3]) {
    call B
  }
  call C { input: i=1 }
  call D { input: x= who + "__x"  }
}

task C {
   Int i
   output {
     Int result = i
   }
}

task D {
  String x
  String? y
  output {
     String result = x
  }
}

Note that there are downsides to this solution. It reduces the opportunitues to batch expression evaluation. In workflow bar, by default, the calculation of sz and the call to allele_freq would be performed in one job. With the flag turned on, these cannot be combined, and we need three separate jobs. In addition, missing call arguments no longer trigger a compile time error.

workflow bar {
   File config
   Float overhead_factor

   call preamble

   Int sz = round(size(config) * overhead_factor)
   call allele_freq { disk_space = sz }
}