dirigent-examples 0.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. dirigent_examples/__init__.py +22 -0
  2. dirigent_examples/py.typed +0 -0
  3. dirigent_examples/shelves/README.md +299 -0
  4. dirigent_examples/shelves/composition/README.md +18 -0
  5. dirigent_examples/shelves/composition/chained-instances.yaml +97 -0
  6. dirigent_examples/shelves/composition/composition-child.yaml +64 -0
  7. dirigent_examples/shelves/composition/composition-parent.yaml +89 -0
  8. dirigent_examples/shelves/connections.yaml +52 -0
  9. dirigent_examples/shelves/demo/README.md +19 -0
  10. dirigent_examples/shelves/demo/markdown-showcase.yaml +117 -0
  11. dirigent_examples/shelves/demo/params-showcase.yaml +92 -0
  12. dirigent_examples/shelves/demo/requires.yaml +65 -0
  13. dirigent_examples/shelves/demo/weekly-import-malawi.yaml +54 -0
  14. dirigent_examples/shelves/demo/weekly-import-nepal.yaml +75 -0
  15. dirigent_examples/shelves/docker/README.md +29 -0
  16. dirigent_examples/shelves/docker/docker-build-push.yaml +110 -0
  17. dirigent_examples/shelves/docker/docker-build-run.yaml +106 -0
  18. dirigent_examples/shelves/docker/docker-compose-database.yaml +124 -0
  19. dirigent_examples/shelves/docker/docker-compose-failing-up.yaml +83 -0
  20. dirigent_examples/shelves/docker/docker-compose-file.yaml +117 -0
  21. dirigent_examples/shelves/docker/docker-compose-profiles-env.yaml +133 -0
  22. dirigent_examples/shelves/docker/docker-compose-stack.yaml +70 -0
  23. dirigent_examples/shelves/docker/docker-hello.yaml +53 -0
  24. dirigent_examples/shelves/docker/docker-remote-daemon.yaml +92 -0
  25. dirigent_examples/shelves/docker/docker-run-failing-teardown.yaml +94 -0
  26. dirigent_examples/shelves/docker/docker-ticker.yaml +49 -0
  27. dirigent_examples/shelves/execute/README.md +16 -0
  28. dirigent_examples/shelves/execute/long-log.yaml +89 -0
  29. dirigent_examples/shelves/failure/README.md +20 -0
  30. dirigent_examples/shelves/failure/error-handler.yaml +89 -0
  31. dirigent_examples/shelves/failure/optional-step.yaml +82 -0
  32. dirigent_examples/shelves/failure/retries.yaml +75 -0
  33. dirigent_examples/shelves/failure/retry-budget.yaml +82 -0
  34. dirigent_examples/shelves/failure/step-timeout.yaml +96 -0
  35. dirigent_examples/shelves/git/README.md +32 -0
  36. dirigent_examples/shelves/git/git-checkout-build.yaml +125 -0
  37. dirigent_examples/shelves/git/git-checkout-compose.yaml +138 -0
  38. dirigent_examples/shelves/git/git-checkout-public.yaml +84 -0
  39. dirigent_examples/shelves/graph/README.md +22 -0
  40. dirigent_examples/shelves/graph/deep-chain.yaml +119 -0
  41. dirigent_examples/shelves/graph/fan-in.yaml +80 -0
  42. dirigent_examples/shelves/graph/fan-out.yaml +66 -0
  43. dirigent_examples/shelves/graph/linear.yaml +66 -0
  44. dirigent_examples/shelves/graph/parallel-branches.yaml +57 -0
  45. dirigent_examples/shelves/graph/parallel-sleep.yaml +55 -0
  46. dirigent_examples/shelves/graph/skip-diamond.yaml +105 -0
  47. dirigent_examples/shelves/graph/wide-fan.yaml +147 -0
  48. dirigent_examples/shelves/hello-world.yaml +30 -0
  49. dirigent_examples/shelves/open-data/README.md +67 -0
  50. dirigent_examples/shelves/open-data/feeds-composition.yaml +120 -0
  51. dirigent_examples/shelves/open-data/gdacs-disaster-updates.yaml +230 -0
  52. dirigent_examples/shelves/open-data/github-releases-relay.yaml +225 -0
  53. dirigent_examples/shelves/open-data/hdx-dataset-watch.yaml +211 -0
  54. dirigent_examples/shelves/open-data/kobo-submissions-to-csv.yaml +149 -0
  55. dirigent_examples/shelves/open-data/nominatim-geocode-facilities.yaml +169 -0
  56. dirigent_examples/shelves/open-data/odk-central-submissions.yaml +146 -0
  57. dirigent_examples/shelves/open-data/open-meteo-weekly-report.yaml +142 -0
  58. dirigent_examples/shelves/open-data/overpass-health-facilities.yaml +154 -0
  59. dirigent_examples/shelves/open-data/usgs-earthquakes-alert.yaml +208 -0
  60. dirigent_examples/shelves/open-data/who-gho-indicators-to-parquet.yaml +163 -0
  61. dirigent_examples/shelves/open-data/wikidata-country-reference.yaml +146 -0
  62. dirigent_examples/shelves/open-data/world-bank-population-trend.yaml +166 -0
  63. dirigent_examples/shelves/patterns/README.md +144 -0
  64. dirigent_examples/shelves/patterns/concurrency-queue.yaml +89 -0
  65. dirigent_examples/shelves/patterns/concurrency-replace.yaml +92 -0
  66. dirigent_examples/shelves/patterns/concurrency-skip.yaml +97 -0
  67. dirigent_examples/shelves/patterns/connections-referenced-vs-carried.yaml +140 -0
  68. dirigent_examples/shelves/patterns/deadline-on-a-sensor.yaml +106 -0
  69. dirigent_examples/shelves/patterns/fan-out-continue.yaml +88 -0
  70. dirigent_examples/shelves/patterns/fan-out-fail-fast.yaml +80 -0
  71. dirigent_examples/shelves/patterns/fan-out-from-params.yaml +84 -0
  72. dirigent_examples/shelves/patterns/fan-out-item-wise.yaml +111 -0
  73. dirigent_examples/shelves/patterns/fan-out-literal-list.yaml +81 -0
  74. dirigent_examples/shelves/patterns/fan-out-nested-objects.yaml +107 -0
  75. dirigent_examples/shelves/patterns/fan-out-then-join.yaml +86 -0
  76. dirigent_examples/shelves/patterns/log-levels.yaml +119 -0
  77. dirigent_examples/shelves/patterns/outputs-inline-vs-storage.yaml +140 -0
  78. dirigent_examples/shelves/patterns/params-every-type.yaml +259 -0
  79. dirigent_examples/shelves/patterns/params-validation-refuses.yaml +131 -0
  80. dirigent_examples/shelves/patterns/pipeline-run-child.yaml +96 -0
  81. dirigent_examples/shelves/patterns/pipeline-run-fire-and-forget.yaml +99 -0
  82. dirigent_examples/shelves/patterns/pipeline-run-strict.yaml +103 -0
  83. dirigent_examples/shelves/patterns/pipeline-run-wait.yaml +107 -0
  84. dirigent_examples/shelves/patterns/pipeline-run-with-params.yaml +124 -0
  85. dirigent_examples/shelves/patterns/poll-cadence.yaml +102 -0
  86. dirigent_examples/shelves/patterns/priority-layered.yaml +120 -0
  87. dirigent_examples/shelves/patterns/references-cheat-sheet.yaml +186 -0
  88. dirigent_examples/shelves/patterns/retry-budget-exhausted.yaml +92 -0
  89. dirigent_examples/shelves/patterns/retry-exponential-backoff.yaml +88 -0
  90. dirigent_examples/shelves/patterns/retry-only-transient.yaml +108 -0
  91. dirigent_examples/shelves/patterns/retry-with-jitter.yaml +102 -0
  92. dirigent_examples/shelves/patterns/rule-all-done.yaml +83 -0
  93. dirigent_examples/shelves/patterns/rule-all-success.yaml +79 -0
  94. dirigent_examples/shelves/patterns/rule-always.yaml +92 -0
  95. dirigent_examples/shelves/patterns/rule-one-failed.yaml +86 -0
  96. dirigent_examples/shelves/patterns/schedule-at-once.yaml +110 -0
  97. dirigent_examples/shelves/patterns/schedule-cron-timezone.yaml +121 -0
  98. dirigent_examples/shelves/patterns/schedule-interval.yaml +109 -0
  99. dirigent_examples/shelves/patterns/schedule-window-half-open.yaml +105 -0
  100. dirigent_examples/shelves/patterns/sensor-http-ready.yaml +121 -0
  101. dirigent_examples/shelves/patterns/sensor-storage-exists.yaml +134 -0
  102. dirigent_examples/shelves/patterns/step-names-and-keys.yaml +99 -0
  103. dirigent_examples/shelves/patterns/timeout-fails-the-step.yaml +94 -0
  104. dirigent_examples/shelves/patterns/timeout-skips-the-step.yaml +102 -0
  105. dirigent_examples/shelves/patterns/webhook-mapping-nested-payload.yaml +125 -0
  106. dirigent_examples/shelves/patterns/webhook-signed.yaml +144 -0
  107. dirigent_examples/shelves/preview/s3-parquet-to-ingestion.yaml +92 -0
  108. dirigent_examples/shelves/python/README.md +31 -0
  109. dirigent_examples/shelves/python/apply_and_run.py +52 -0
  110. dirigent_examples/shelves/python/ci_gate.py +76 -0
  111. dirigent_examples/shelves/python/connections.py +61 -0
  112. dirigent_examples/shelves/python/error_handling.py +84 -0
  113. dirigent_examples/shelves/python/follow_logs.py +39 -0
  114. dirigent_examples/shelves/python/list_and_filter.py +52 -0
  115. dirigent_examples/shelves/queues/README.md +59 -0
  116. dirigent_examples/shelves/queues/kafka-consume-then-transform.yaml +105 -0
  117. dirigent_examples/shelves/queues/kafka-produce-then-consume.yaml +124 -0
  118. dirigent_examples/shelves/queues/rabbitmq-consume-ack-on-success.yaml +102 -0
  119. dirigent_examples/shelves/queues/report-to-kafka.yaml +105 -0
  120. dirigent_examples/shelves/queues/report-to-rabbitmq.yaml +105 -0
  121. dirigent_examples/shelves/recipes/README.md +130 -0
  122. dirigent_examples/shelves/recipes/csv-header-rules.yaml +147 -0
  123. dirigent_examples/shelves/recipes/csv-to-ndjson.yaml +107 -0
  124. dirigent_examples/shelves/recipes/etl-csv-clean-validate-parquet.yaml +207 -0
  125. dirigent_examples/shelves/recipes/filter-by-predicate.yaml +116 -0
  126. dirigent_examples/shelves/recipes/filter-then-map-then-reduce.yaml +109 -0
  127. dirigent_examples/shelves/recipes/http-fetch-validate-post.yaml +142 -0
  128. dirigent_examples/shelves/recipes/http-follow-redirects.yaml +100 -0
  129. dirigent_examples/shelves/recipes/http-get-with-query.yaml +96 -0
  130. dirigent_examples/shelves/recipes/http-headers-and-auth-connection.yaml +110 -0
  131. dirigent_examples/shelves/recipes/http-post-file-from-storage.yaml +117 -0
  132. dirigent_examples/shelves/recipes/http-post-json-echo.yaml +105 -0
  133. dirigent_examples/shelves/recipes/http-post-report.yaml +183 -0
  134. dirigent_examples/shelves/recipes/http-save-body-to-storage.yaml +106 -0
  135. dirigent_examples/shelves/recipes/http-success-status-list.yaml +80 -0
  136. dirigent_examples/shelves/recipes/http-timeout-override.yaml +104 -0
  137. dirigent_examples/shelves/recipes/jq-dedupe-by-key.yaml +78 -0
  138. dirigent_examples/shelves/recipes/jq-defaults-and-nulls.yaml +91 -0
  139. dirigent_examples/shelves/recipes/jq-group-by-and-sum.yaml +74 -0
  140. dirigent_examples/shelves/recipes/jq-join-two-lists.yaml +77 -0
  141. dirigent_examples/shelves/recipes/jq-long-to-wide.yaml +76 -0
  142. dirigent_examples/shelves/recipes/jq-nested-to-flat.yaml +89 -0
  143. dirigent_examples/shelves/recipes/jq-pivot-wide-to-long.yaml +65 -0
  144. dirigent_examples/shelves/recipes/jq-running-totals.yaml +82 -0
  145. dirigent_examples/shelves/recipes/jq-string-cleaning.yaml +88 -0
  146. dirigent_examples/shelves/recipes/jq-top-n.yaml +85 -0
  147. dirigent_examples/shelves/recipes/jq-validate-in-jq-vs-schema.yaml +124 -0
  148. dirigent_examples/shelves/recipes/jq-window-dates.yaml +82 -0
  149. dirigent_examples/shelves/recipes/json-to-csv-flattening.yaml +141 -0
  150. dirigent_examples/shelves/recipes/large-output-to-storage.yaml +134 -0
  151. dirigent_examples/shelves/recipes/map-enrich-with-lookup.yaml +96 -0
  152. dirigent_examples/shelves/recipes/ndjson-to-parquet.yaml +130 -0
  153. dirigent_examples/shelves/recipes/pagination-by-fan-out.yaml +115 -0
  154. dirigent_examples/shelves/recipes/parquet-round-trip-types.yaml +163 -0
  155. dirigent_examples/shelves/recipes/reconcile-two-sources.yaml +159 -0
  156. dirigent_examples/shelves/recipes/report-built-in.yaml +72 -0
  157. dirigent_examples/shelves/recipes/report-daily-digest.yaml +186 -0
  158. dirigent_examples/shelves/recipes/report-to-file.yaml +131 -0
  159. dirigent_examples/shelves/recipes/report-to-webhook.yaml +136 -0
  160. dirigent_examples/shelves/recipes/schema-carried.yaml +112 -0
  161. dirigent_examples/shelves/recipes/schema-formats.yaml +107 -0
  162. dirigent_examples/shelves/recipes/schema-referenced.yaml +86 -0
  163. dirigent_examples/shelves/recipes/schema-refuses-then-rule.yaml +127 -0
  164. dirigent_examples/shelves/recipes/storage-copy-dated-archive.yaml +114 -0
  165. dirigent_examples/shelves/recipes/storage-exists-gate.yaml +127 -0
  166. dirigent_examples/shelves/recipes/storage-manifest-of-a-fan-out.yaml +104 -0
  167. dirigent_examples/shelves/recipes/storage-write-then-read.yaml +119 -0
  168. dirigent_examples/shelves/recipes/webhook-post-hmac.yaml +132 -0
  169. dirigent_examples/shelves/recipes/webhook-post-summary.yaml +142 -0
  170. dirigent_examples/shelves/s3/README.md +34 -0
  171. dirigent_examples/shelves/s3/report-to-s3.yaml +93 -0
  172. dirigent_examples/shelves/s3/s3-copy-and-verify.yaml +105 -0
  173. dirigent_examples/shelves/s3/s3-csv-report.yaml +87 -0
  174. dirigent_examples/shelves/s3/s3-parquet-report.yaml +77 -0
  175. dirigent_examples/shelves/s3/s3-round-trip.yaml +125 -0
  176. dirigent_examples/shelves/schemas/README.md +36 -0
  177. dirigent_examples/shelves/schemas/echo-reading.json +18 -0
  178. dirigent_examples/shelves/schemas/ou-record.json +13 -0
  179. dirigent_examples/shelves/schemas/station-reading.json +13 -0
  180. dirigent_examples/shelves/sensors/README.md +16 -0
  181. dirigent_examples/shelves/sensors/sensor-gate.yaml +65 -0
  182. dirigent_examples/shelves/sensors/time-window.yaml +61 -0
  183. dirigent_examples/shelves/sql/README.md +52 -0
  184. dirigent_examples/shelves/sql/duckdb-parquet-to-report.yaml +146 -0
  185. dirigent_examples/shelves/sql/sql-postgres-readonly.yaml +111 -0
  186. dirigent_examples/shelves/sql/sql-query-to-storage.yaml +85 -0
  187. dirigent_examples/shelves/sql/sql-sqlite-roundtrip.yaml +114 -0
  188. dirigent_examples/shelves/sql/warehouse.sql +42 -0
  189. dirigent_examples/shelves/transform/README.md +36 -0
  190. dirigent_examples/shelves/transform/csv-report.yaml +55 -0
  191. dirigent_examples/shelves/transform/jq-filter-and-map.yaml +70 -0
  192. dirigent_examples/shelves/transform/jq-group-and-aggregate.yaml +70 -0
  193. dirigent_examples/shelves/transform/jq-join-two-sources.yaml +98 -0
  194. dirigent_examples/shelves/transform/jq-reshape.yaml +91 -0
  195. dirigent_examples/shelves/transform/jq-stream-through-storage.yaml +112 -0
  196. dirigent_examples/shelves/transform/ndjson-round-trip.yaml +56 -0
  197. dirigent_examples/shelves/transform/parquet-round-trip.yaml +68 -0
  198. dirigent_examples/shelves/transform/std-convert-fan-out.yaml +142 -0
  199. dirigent_examples/shelves/transform/xml-feed-to-ndjson.yaml +116 -0
  200. dirigent_examples/shelves/transform/yaml-config-to-json.yaml +104 -0
  201. dirigent_examples/shelves/triggers/README.md +45 -0
  202. dirigent_examples/shelves/triggers/at-one-time.yaml +78 -0
  203. dirigent_examples/shelves/triggers/cron-nightly.yaml +79 -0
  204. dirigent_examples/shelves/triggers/cron-windowed.yaml +86 -0
  205. dirigent_examples/shelves/triggers/document-nightly.yaml +80 -0
  206. dirigent_examples/shelves/triggers/interval-rolling.yaml +88 -0
  207. dirigent_examples/shelves/triggers/managed-and-manual.yaml +109 -0
  208. dirigent_examples/shelves/triggers/webhook-trigger.yaml +75 -0
  209. dirigent_examples/shelves/validate/README.md +31 -0
  210. dirigent_examples/shelves/validate/expects-a-shape.yaml +56 -0
  211. dirigent_examples/shelves/validate/the-shape-is-wrong.yaml +46 -0
  212. dirigent_examples-0.15.0.dist-info/METADATA +21 -0
  213. dirigent_examples-0.15.0.dist-info/RECORD +216 -0
  214. dirigent_examples-0.15.0.dist-info/WHEEL +4 -0
  215. dirigent_examples-0.15.0.dist-info/entry_points.txt +3 -0
  216. dirigent_examples-0.15.0.dist-info/licenses/LICENSE +18 -0
@@ -0,0 +1,159 @@
1
+ # Two systems that should agree, and a run that says exactly how they do not.
2
+ #
3
+ # In: the same day's totals from a source of record and from a warehouse, each with rows
4
+ # the other does not have.
5
+ # Out: {matched, only_in_source, only_in_target, differing, totals} and a run that fails
6
+ # when the disagreement is bigger than the tolerance.
7
+ #
8
+ # A reconciliation has exactly four buckets, and a report that does not name all four is not
9
+ # a reconciliation:
10
+ #
11
+ # in both, equal the boring bucket, and the one worth counting so the others have a
12
+ # denominator.
13
+ # in both, different the interesting bucket. Each row carries both values and their
14
+ # difference, because "17 rows differ" cannot be investigated.
15
+ # only in the source something that has not arrived yet, or a load that dropped it.
16
+ # only in the target something deleted upstream, or loaded twice under a changed key.
17
+ #
18
+ # The comparison is by key, so both sides are indexed first with INDEX and the key set is
19
+ # the union of the two -- not the source's keys, which would make anything extra in the
20
+ # target invisible.
21
+ #
22
+ # Floating point is the trap in the equality test. Two systems that both computed 0.1 + 0.2
23
+ # do not agree on it, so the comparison is a tolerance rather than ==, and the tolerance is
24
+ # a parameter because what counts as equal is a property of the data and not of the code.
25
+ #
26
+ # The verdict is the last decision: a difference that is within tolerance is not a
27
+ # difference, a small number of differing rows is a warning somebody reads, and past the
28
+ # threshold the run fails so that a schedule stops rather than reporting quietly for a week.
29
+ #
30
+ # To change it: -p max_differing=5 lets this run pass with its three differing rows; the
31
+ # default of 2 is below that, so the default run fails on purpose.
32
+ #
33
+ # dg run --local examples/recipes/reconcile-two-sources.yaml # fails, on purpose
34
+ # dg run --local examples/recipes/reconcile-two-sources.yaml -p max_differing=5
35
+
36
+ format: dirigent/v1
37
+ kind: pipeline
38
+ code: reconcile-two-sources
39
+ name: Reconcile two sources
40
+ description: Compare two systems' totals by key with a tolerance, report the four buckets, and fail when too many rows disagree.
41
+
42
+ tags: [recipes, transform]
43
+
44
+ requires:
45
+ blocks:
46
+ - value.const
47
+ - transform.jq
48
+
49
+ params:
50
+ type: object
51
+ properties:
52
+ day:
53
+ type: string
54
+ format: date
55
+ default: "2026-01-01"
56
+ description: The day both sides are reporting on.
57
+ tolerance:
58
+ type: number
59
+ minimum: 0
60
+ default: 0.01
61
+ description: How far two values may differ and still count as equal.
62
+ max_differing:
63
+ type: integer
64
+ minimum: 0
65
+ default: 2
66
+ description: How many differing rows are tolerated before the run fails.
67
+
68
+ steps:
69
+ source:
70
+ block: value.const
71
+ config:
72
+ value:
73
+ - { key: east/pump, total: 300.0 }
74
+ - { key: east/valve, total: 250.0 }
75
+ - { key: west/pump, total: 100.0 }
76
+ - { key: west/valve, total: 100.0 }
77
+ # In the source and not in the warehouse: not loaded yet, or dropped.
78
+ - { key: north/pump, total: 200.0 }
79
+ - { key: south/pump, total: 42.5 }
80
+
81
+ target:
82
+ block: value.const
83
+ config:
84
+ value:
85
+ - { key: east/pump, total: 300.0 }
86
+ # Differs by more than the tolerance.
87
+ - { key: east/valve, total: 247.5 }
88
+ # Differs by less: floating-point noise, not a disagreement.
89
+ - { key: west/pump, total: 100.000000001 }
90
+ - { key: west/valve, total: 90.0 }
91
+ - { key: south/pump, total: 41.0 }
92
+ # In the warehouse and not in the source: deleted upstream, or loaded twice.
93
+ - { key: east/gasket, total: 12.0 }
94
+
95
+ compare:
96
+ block: transform.jq
97
+ depends_on: [source, target]
98
+ config:
99
+ input:
100
+ source: ${steps.source.output.value}
101
+ target: ${steps.target.output.value}
102
+ tolerance: ${params.tolerance}
103
+ day: ${params.day}
104
+ program: |
105
+ . as {$source, $target, $tolerance, $day}
106
+ | ($source | INDEX(.key)) as $left
107
+ | ($target | INDEX(.key)) as $right
108
+ # The union of both key sets: the source's keys alone would hide anything extra in
109
+ # the target.
110
+ | (([$source[].key] + [$target[].key]) | unique) as $keys
111
+ | [$keys[]
112
+ | . as $key
113
+ | {key: $key,
114
+ source: ($left[$key].total // null),
115
+ target: ($right[$key].total // null)}
116
+ | . + {bucket: (if .source == null then "only_in_target"
117
+ elif .target == null then "only_in_source"
118
+ # A tolerance, not ==: two systems that both computed the same
119
+ # sum in floating point do not agree on its last bits.
120
+ elif ((.source - .target) | fabs) <= $tolerance then "matched"
121
+ else "differing" end),
122
+ difference: (if .source == null or .target == null then null
123
+ else ((.source - .target) * 1000 | round / 1000) end)}]
124
+ as $rows
125
+ | {day: $day,
126
+ tolerance: $tolerance,
127
+ matched: [$rows[] | select(.bucket == "matched") | .key],
128
+ only_in_source: [$rows[] | select(.bucket == "only_in_source") | .key],
129
+ only_in_target: [$rows[] | select(.bucket == "only_in_target") | .key],
130
+ # Both values and the difference, because a count of differing rows cannot be
131
+ # investigated by anybody.
132
+ differing: [$rows[] | select(.bucket == "differing")
133
+ | {key, source, target, difference}],
134
+ totals: {source: ([$source[].total] | add),
135
+ target: ([$target[].total] | add),
136
+ keys: ($keys | length)}}
137
+
138
+ verdict:
139
+ block: transform.jq
140
+ depends_on: [compare]
141
+ config:
142
+ input:
143
+ report: ${steps.compare.output.value}
144
+ max_differing: ${params.max_differing}
145
+ program: |
146
+ . as {$report, $max_differing}
147
+ | ($report.differing | length) as $differing
148
+ # Past the threshold the run fails, so a schedule stops instead of reporting a
149
+ # widening gap quietly every morning.
150
+ | if $differing > $max_differing
151
+ then error("\($differing) rows differ beyond the tolerance, and \($max_differing) "
152
+ + "is the limit: \([$report.differing[].key] | join(", "))")
153
+ else {day: $report.day,
154
+ differing: $differing,
155
+ matched: ($report.matched | length),
156
+ only_in_source: $report.only_in_source,
157
+ only_in_target: $report.only_in_target,
158
+ gap: (($report.totals.source - $report.totals.target) * 100 | round / 100)}
159
+ end
@@ -0,0 +1,72 @@
1
+ # Ask for a run's report and write no template at all.
2
+ #
3
+ # In: five sales rows, inline.
4
+ # Out: a total per region, and the run's own report document -- the built-in one, because
5
+ # the report: section is empty.
6
+ #
7
+ # `report: {}` is the whole of the opt-in. A document with no report: section renders
8
+ # nothing; an empty section renders the page the engine ships:
9
+ #
10
+ # a title the pipeline's name, or its code when it has none, and the run's status
11
+ # the facts run id, pipeline and version, trigger, started, finished, duration, when the
12
+ # page was rendered, and the data window when the run has one
13
+ # the steps a row per step: outcome, attempts, how long it took, how large its output was,
14
+ # and how many warnings it logged, which a succeeded step can still have
15
+ # the items the elements that failed, when a fan-out step had any, each with the step it
16
+ # failed at and the error
17
+ # the error the run's own error, or a cancellation's reason, when it ended badly
18
+ #
19
+ # That page is written by the engine rather than by a step, which is the point of it: a run
20
+ # that was cancelled, or whose worker died, has no step left to write anything, and those
21
+ # are the runs whose account is worth the most. It is rendered in the transaction that
22
+ # settles the run, and rewritten in place if a retry reopens the run and it settles again.
23
+ #
24
+ # The two steps are here to give the page something to tabulate, and they are the smallest
25
+ # pair that does: a literal, and one jq program over it.
26
+ #
27
+ # To change it: write a `template:` under report: and the page is yours instead --
28
+ # http-post-report.yaml is that document, and docs/reports.md lists every fact a template
29
+ # may read.
30
+ #
31
+ # dg run --local examples/recipes/report-built-in.yaml
32
+ # dg apply examples/recipes/report-built-in.yaml
33
+ # dg run report-built-in --watch
34
+ # dg runs report RUN_ID --markdown
35
+
36
+ format: dirigent/v1
37
+ kind: pipeline
38
+ code: report-built-in
39
+ name: The built-in report
40
+ description: The smallest pipeline that renders a report document, with an empty report section and no template of its own.
41
+
42
+ tags: [recipes, transform, report, starter]
43
+
44
+ requires:
45
+ blocks:
46
+ - value.const
47
+ - transform.jq
48
+
49
+ steps:
50
+ rows:
51
+ block: value.const
52
+ config:
53
+ value:
54
+ - { region: east, revenue: 300 }
55
+ - { region: east, revenue: 250 }
56
+ - { region: west, revenue: 100 }
57
+ - { region: west, revenue: 100 }
58
+ - { region: north, revenue: 200 }
59
+
60
+ totals:
61
+ block: transform.jq
62
+ depends_on: [rows]
63
+ config:
64
+ input: ${steps.rows.output.value}
65
+ program: |
66
+ {regions: (group_by(.region)
67
+ | map({region: .[0].region,
68
+ rows: length,
69
+ revenue: (map(.revenue) | add)})),
70
+ revenue: (map(.revenue) | add)}
71
+
72
+ report: {}
@@ -0,0 +1,186 @@
1
+ # A daily digest: fetch, aggregate, write a csv somebody opens, and post the summary.
2
+ #
3
+ # In: a day, as a parameter. The readings are generated so the recipe runs anywhere; the
4
+ # one HTTP call is the delivery at the end.
5
+ # Out: a csv artifact per region in the run's scratch space, a markdown-ish digest line, and
6
+ # a delivered notification whose body carries the numbers.
7
+ #
8
+ # This is the shape most reporting pipelines end up with, and the reason each piece is
9
+ # separate:
10
+ #
11
+ # window the day's boundaries are computed once, from the parameter and not from the
12
+ # clock, so a rerun for last Tuesday reports last Tuesday. A pipeline that
13
+ # reads `now` cannot be rerun, and being rerunnable is most of what makes an
14
+ # incident survivable.
15
+ # readings stands in for the source read. Replacing it with an http.request whose body
16
+ # this step reads changes this step and nothing below it.
17
+ # aggregate the numbers, computed once, in one place. Everything below reads them rather
18
+ # than recomputing, so the csv, the digest line and the notification can never
19
+ # disagree.
20
+ # rows_file the per-region numbers as an object in the run's scratch space. A conversion
21
+ # reads a URI and writes a URI, so the value goes out through storage.write
22
+ # before the codec sees it.
23
+ # csv the artifact somebody actually opens, converted from that object under a
24
+ # dated name.
25
+ # digest the human sentence, derived from the aggregate.
26
+ # notify the delivery, on rule all_done so a failed report still says something.
27
+ #
28
+ # A digest has to say what it does not know. The empty_regions field is there because a
29
+ # region that reported nothing looks exactly like a region that reported zero once the
30
+ # averages are taken, and only the pipeline knows the difference.
31
+ #
32
+ # To change it: -p day=2026-02-03 reruns for another day; -p regions='["east"]' narrows it.
33
+ #
34
+ # dg run --local examples/recipes/report-daily-digest.yaml
35
+ # dg run --local examples/recipes/report-daily-digest.yaml -p day=2026-02-03
36
+
37
+ format: dirigent/v1
38
+ kind: pipeline
39
+ code: report-daily-digest
40
+ name: A daily digest report
41
+ description: Compute a day's numbers once, write them to storage and convert them into a csv artifact, derive a digest line, and post the summary to a receiver.
42
+
43
+ tags: [recipes, storage, transform, webhook]
44
+
45
+ requires:
46
+ blocks:
47
+ - transform.jq
48
+ - storage.write
49
+ - convert.std
50
+ - webhook.post
51
+
52
+ connections:
53
+ echo-webhook:
54
+ kind: http
55
+ config:
56
+ base_url: https://postman-echo.com
57
+ timeout: 30s
58
+ health_path: /get
59
+
60
+ params:
61
+ type: object
62
+ properties:
63
+ day:
64
+ type: string
65
+ format: date
66
+ default: "2026-01-01"
67
+ description: The day being reported on; every date in the run comes from here.
68
+ regions:
69
+ type: array
70
+ default: [east, west, north]
71
+ items:
72
+ type: string
73
+ description: The regions the digest covers, including any that report nothing.
74
+
75
+ steps:
76
+ window:
77
+ block: transform.jq
78
+ config:
79
+ input: ${params.day}
80
+ program: |
81
+ . as $day
82
+ | ($day + "T00:00:00Z" | fromdateiso8601) as $from
83
+ | {day: $day,
84
+ start: ($from | todate),
85
+ end: ($from + 86400 | todate),
86
+ iso_week: ($from | strftime("%G-W%V"))}
87
+
88
+ readings:
89
+ block: transform.jq
90
+ depends_on: [window]
91
+ config:
92
+ input: ${steps.window.output.value}
93
+ # Stands in for the source read: an http.request, or a storage.read of yesterday's drop.
94
+ # north is absent on purpose, so the digest has something to not know.
95
+ program: |
96
+ . as {$day}
97
+ | [range(24) as $hour
98
+ | ["east", "west"] as $regions
99
+ | $regions[]
100
+ | {region: ., day: $day,
101
+ hour: $hour,
102
+ celsius: ((($hour * 3) % 19) - 6),
103
+ station: "\(.)-st-\($hour % 3)"}]
104
+
105
+ aggregate:
106
+ block: transform.jq
107
+ depends_on: [readings, window]
108
+ config:
109
+ input:
110
+ rows: ${steps.readings.output.value}
111
+ window: ${steps.window.output.value}
112
+ regions: ${params.regions}
113
+ program: |
114
+ . as {$rows, $window, $regions}
115
+ | ($rows | group_by(.region)
116
+ | map({region: .[0].region,
117
+ readings: length,
118
+ stations: ([.[].station] | unique | length),
119
+ mean_celsius: ((map(.celsius) | add) / length * 10 | round / 10),
120
+ min_celsius: (map(.celsius) | min),
121
+ max_celsius: (map(.celsius) | max)})) as $per_region
122
+ | {day: $window.day,
123
+ iso_week: $window.iso_week,
124
+ regions: $per_region,
125
+ total_readings: ($rows | length),
126
+ # A region that reported nothing is not a region that reported zero, and only
127
+ # this pipeline knows which happened.
128
+ empty_regions: ($regions - [$per_region[].region])}
129
+
130
+ rows_file:
131
+ block: storage.write
132
+ depends_on: [aggregate]
133
+ config:
134
+ target: ${run.scratch}/digest-${params.day}.json
135
+ value: ${steps.aggregate.output.value.regions}
136
+
137
+ csv:
138
+ block: convert.std
139
+ depends_on: [rows_file]
140
+ config:
141
+ source: ${steps.rows_file.output.uri}
142
+ target: ${run.scratch}/digest-${params.day}.csv
143
+ from: json
144
+ to: csv
145
+
146
+ digest:
147
+ block: transform.jq
148
+ depends_on: [aggregate, csv]
149
+ config:
150
+ input:
151
+ summary: ${steps.aggregate.output.value}
152
+ file: ${steps.csv.output.target}
153
+ program: |
154
+ .summary as $s
155
+ | {day: $s.day,
156
+ iso_week: $s.iso_week,
157
+ file: .file,
158
+ # Derived from the numbers, so it cannot disagree with them.
159
+ text: "\($s.day) (\($s.iso_week)): \($s.total_readings) readings across "
160
+ + "\($s.regions | length) regions"
161
+ + (if ($s.empty_regions | length) > 0
162
+ then "; no data from \($s.empty_regions | join(", "))"
163
+ else "" end),
164
+ lines: [$s.regions[]
165
+ | "\(.region): \(.readings) readings, mean \(.mean_celsius)C "
166
+ + "(\(.min_celsius) to \(.max_celsius))"]}
167
+
168
+ notify:
169
+ block: webhook.post
170
+ depends_on: [digest]
171
+ rule: all_done
172
+ config:
173
+ connection: echo-webhook
174
+ path: /post
175
+ body: ${steps.digest.output.value}
176
+ success_status: [200, 202, 204]
177
+
178
+ delivered:
179
+ block: transform.jq
180
+ depends_on: [notify]
181
+ config:
182
+ input:
183
+ status: ${steps.notify.output.status}
184
+ echoed: ${steps.notify.output.json_body}
185
+ program: |
186
+ {status, text: .echoed.json.text, lines: .echoed.json.lines, file: .echoed.json.file}
@@ -0,0 +1,131 @@
1
+ # Render a page of markdown and put it in a file, then read the file back and log it.
2
+ #
3
+ # In: three readings written inline.
4
+ # Out: a markdown page at ${run.scratch}/report.md, and its text on the run's own timeline.
5
+ #
6
+ # report.render renders text and hands it on; it writes nothing itself. Sending it
7
+ # somewhere is a step of its own, and which step that is decides where the page goes -- a
8
+ # file here, a bucket in examples/s3/report-to-s3.yaml, a queue in the two under
9
+ # examples/queues/. The template does not change between them.
10
+ #
11
+ # Six hops, and what each one hands on:
12
+ # readings value.const, the rows a real pipeline would have fetched.
13
+ # summary transform.jq, the numbers the page is about, computed before anything is
14
+ # worded. A template that does arithmetic is a template nobody can test.
15
+ # page report.render, the markdown. Output: text, content_type, text_bytes.
16
+ # save storage.write, the text to a URI. Output: uri, bytes_written, content_type.
17
+ # back storage.read, the same URI. It is text/markdown, so the object comes back as
18
+ # `text` rather than as `value`.
19
+ # show log.write, the page onto the run's timeline, which is the smallest sink there
20
+ # is and the easiest way to see that the round trip worked.
21
+ #
22
+ # THE TEMPLATE IS JINJA, and `values` is everything it may read: names it was not given
23
+ # render as the empty string rather than failing the step, so a page missing a fact is still
24
+ # a page. It is compiled when the document is applied, so a syntax error is refused there
25
+ # rather than at the first run. docs/reports.md is the family's home; the same language
26
+ # writes a run's own `report:` section.
27
+ #
28
+ # WHY THE CONTENT TYPE IS SAID TWICE. report.render says what the text is and storage.write
29
+ # records it, so the object carries its type on a backend that can hold one and is guessed
30
+ # from the `.md` extension on one that cannot. storage.read resolves it the same way round:
31
+ # an override, else what the backend stored, else the extension, else bytes it refuses to
32
+ # decode.
33
+ #
34
+ # TO MAKE IT YOURS: change the template, and change where `save` points. Nothing else moves.
35
+ #
36
+ # dg run --local examples/recipes/report-to-file.yaml
37
+ # dg run --local examples/recipes/report-to-file.yaml -p day=2026-02-01
38
+
39
+ format: dirigent/v1
40
+ kind: pipeline
41
+ code: report-to-file
42
+ name: Render a report and write it to a file
43
+ description: Render a markdown page from a Jinja template, write it to the run's scratch space, and read it back.
44
+
45
+ tags: [recipes, report, storage, transform, starter]
46
+
47
+ requires:
48
+ blocks:
49
+ - value.const
50
+ - transform.jq
51
+ - report.render
52
+ - storage.write
53
+ - storage.read
54
+ - log.write
55
+
56
+ params:
57
+ type: object
58
+ properties:
59
+ day:
60
+ type: string
61
+ format: date
62
+ default: "2026-01-01"
63
+ description: The day the report is about.
64
+
65
+ steps:
66
+ readings:
67
+ block: value.const
68
+ config:
69
+ value:
70
+ - {station: st-1, region: east, celsius: 4.5}
71
+ - {station: st-3, region: west, celsius: 1.2}
72
+ - {station: st-4, region: north, celsius: -3.4}
73
+
74
+ summary:
75
+ block: transform.jq
76
+ depends_on: [readings]
77
+ config:
78
+ input: ${steps.readings.output.value}
79
+ # Every number the page shows is computed here, where it can be asserted on.
80
+ program: |
81
+ {stations: length,
82
+ coldest: (min_by(.celsius) | .station),
83
+ below_zero: [.[] | select(.celsius < 0) | .station],
84
+ rows: [.[] | {station, region, celsius}]}
85
+
86
+ page:
87
+ block: report.render
88
+ depends_on: [summary]
89
+ config:
90
+ # What the template may read, and the only thing it may read.
91
+ values:
92
+ day: ${params.day}
93
+ summary: ${steps.summary.output.value}
94
+ content_type: text/markdown
95
+ template: |
96
+ # Readings for {{ day }}
97
+
98
+ {{ summary.stations }} stations reported, {{ summary.below_zero | length }} below zero.
99
+ The coldest was {{ summary.coldest }}.
100
+
101
+ | Station | Region | Celsius |
102
+ | --- | --- | --- |
103
+ {% for row in summary.rows %}
104
+ | {{ row.station }} | {{ row.region }} | {{ row.celsius }} |
105
+ {% endfor %}
106
+
107
+ save:
108
+ block: storage.write
109
+ depends_on: [page]
110
+ config:
111
+ # ${run.scratch} is the run's own prefix in whatever storage the instance is
112
+ # configured with: a temporary directory under --local, the artifact root on a
113
+ # server, a bucket where an s3 connection serves the scheme.
114
+ target: ${run.scratch}/report.md
115
+ text: ${steps.page.output.text}
116
+ content_type: ${steps.page.output.content_type}
117
+
118
+ back:
119
+ block: storage.read
120
+ depends_on: [save]
121
+ config:
122
+ # Where the write said the object landed, not the same path typed twice.
123
+ source: ${steps.save.output.uri}
124
+ max_size: 1mb
125
+
126
+ show:
127
+ block: log.write
128
+ depends_on: [back]
129
+ config:
130
+ message: the report, read back out of storage
131
+ value: ${steps.back.output.text}
@@ -0,0 +1,136 @@
1
+ # Render a page and POST it to a receiver, which is how a report reaches a chat client or a
2
+ # ticket without anything on this side storing it.
3
+ #
4
+ # In: a batch of load results, some of them rejected.
5
+ # Out: a markdown page delivered to Postman Echo, and what the receiver echoed back.
6
+ #
7
+ # Three hops, and what each one hands on:
8
+ # summary transform.jq, the counts and the identifiers, computed before anything is
9
+ # worded, so the prose can never disagree with the numbers.
10
+ # page report.render, the markdown. Output: text, content_type, text_bytes.
11
+ # deliver webhook.post, a JSON body carrying that text. Output: status, signed,
12
+ # duration_ms, and the receiver's answer.
13
+ #
14
+ # THE BODY IS AN OBJECT, NOT THE PAGE ALONE. webhook.post sends JSON, so the page travels as
15
+ # one field of a small envelope beside the facts a receiver routes on: the pipeline, the day,
16
+ # the severity, and the content type of the text. A receiver that renders markdown reads
17
+ # `text`; one that only routes reads the fields beside it and never parses the page.
18
+ #
19
+ # WHY report.render RATHER THAN A jq STRING. A page built by string concatenation in jq is a
20
+ # page nobody can read in the document. The template is text, indented as it will be
21
+ # rendered, and the numbers in it come from `values` -- and a name that is not there renders
22
+ # empty rather than failing the delivery.
23
+ #
24
+ # THE DELIVERY IS A STEP IN THE DAG, so a rule decides when it runs. This one is `all_done`,
25
+ # so the report is sent whether the work above it succeeded or not: a report that only goes
26
+ # out on success is one that goes quiet exactly when somebody needs it.
27
+ #
28
+ # TO CHANGE IT: -p failures=0 sends the green version, and the severity and the wording both
29
+ # follow from the numbers without another branch in the document.
30
+ #
31
+ # dg run --local examples/recipes/report-to-webhook.yaml
32
+ # dg run --local examples/recipes/report-to-webhook.yaml -p failures=0
33
+
34
+ format: dirigent/v1
35
+ kind: pipeline
36
+ code: report-to-webhook
37
+ name: Render a report and POST it
38
+ description: Render a markdown page from a Jinja template and deliver it to a receiver as one field of a webhook body.
39
+
40
+ tags: [recipes, report, transform, webhook]
41
+
42
+ requires:
43
+ blocks:
44
+ - transform.jq
45
+ - report.render
46
+ - webhook.post
47
+
48
+ connections:
49
+ echo-webhook:
50
+ kind: http
51
+ config:
52
+ base_url: https://postman-echo.com
53
+ timeout: 30s
54
+ health_path: /get
55
+
56
+ params:
57
+ type: object
58
+ properties:
59
+ day:
60
+ type: string
61
+ format: date
62
+ default: "2026-01-01"
63
+ description: The day the load reports on.
64
+ rows:
65
+ type: integer
66
+ minimum: 1
67
+ default: 12
68
+ description: How many records the load handled.
69
+ failures:
70
+ type: integer
71
+ minimum: 0
72
+ default: 3
73
+ description: How many of them were rejected; 0 sends the green version.
74
+
75
+ steps:
76
+ summary:
77
+ block: transform.jq
78
+ config:
79
+ input:
80
+ rows: ${params.rows}
81
+ failures: ${params.failures}
82
+ day: ${params.day}
83
+ program: |
84
+ . as {$rows, $failures, $day}
85
+ | [range($rows)
86
+ | {key: "\($day)/rec-\(.)",
87
+ status: (if . < $failures then "rejected" else "loaded" end)}]
88
+ | {day: $day,
89
+ rows: length,
90
+ loaded: ([.[] | select(.status == "loaded")] | length),
91
+ rejected: ([.[] | select(.status == "rejected")] | length),
92
+ # The keys, so a reader can look them up; not a count of them, which nobody can act on.
93
+ rejected_keys: [.[] | select(.status == "rejected") | .key]}
94
+ | . + {severity: (if .rejected == 0 then "info"
95
+ elif .rejected < (.rows / 2) then "warning"
96
+ else "error" end)}
97
+
98
+ page:
99
+ block: report.render
100
+ depends_on: [summary]
101
+ config:
102
+ values:
103
+ summary: ${steps.summary.output.value}
104
+ content_type: text/markdown
105
+ template: |
106
+ # Load for {{ summary.day }}
107
+
108
+ {{ summary.loaded }} of {{ summary.rows }} records loaded, {{ summary.rejected }} rejected.
109
+
110
+ {% if summary.rejected_keys %}
111
+ Rejected:
112
+ {% for key in summary.rejected_keys %}
113
+ - `{{ key }}`
114
+ {% endfor %}
115
+ {% else %}
116
+ Nothing was rejected.
117
+ {% endif %}
118
+
119
+ deliver:
120
+ block: webhook.post
121
+ depends_on: [page]
122
+ # Sent whether the work above passed or failed.
123
+ rule: all_done
124
+ config:
125
+ connection: echo-webhook
126
+ path: /post
127
+ body:
128
+ pipeline: report-to-webhook
129
+ day: ${params.day}
130
+ severity: ${steps.summary.output.value.severity}
131
+ content_type: ${steps.page.output.content_type}
132
+ text: ${steps.page.output.text}
133
+ headers:
134
+ # Routing the receiver can dispatch on without reading the page.
135
+ x-severity: ${steps.summary.output.value.severity}
136
+ success_status: [200, 202, 204]