@forzalabs/remora 2.3.0 → 2.5.0
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.
- package/CHANGELOG.md +93 -0
- package/docs/Remora - Code Sets.md +268 -0
- package/docs/Remora - Consumers.md +2224 -0
- package/docs/Remora - Producers.md +1658 -0
- package/docs/Remora - Sources.md +1150 -0
- package/documentation/README.md +262 -133
- package/documentation/default_resources/mock_data.csv +2 -2
- package/index.js +2891 -1955
- package/json_schemas/consumer-schema.json +154 -10
- package/json_schemas/producer-schema.json +0 -38
- package/package.json +4 -2
- package/workers/ExecutorWorker.js +1327 -403
|
@@ -0,0 +1,2224 @@
|
|
|
1
|
+
# Remora - Consumers
|
|
2
|
+
|
|
3
|
+
## **Overview**
|
|
4
|
+
|
|
5
|
+
Consumers in Remora define how data is translated, transformed, combined, and output from producers. They act as the data transformation and output layer, providing field selection, data joins, filtering, transformations, trigger configuration, and export capabilities.
|
|
6
|
+
|
|
7
|
+
Each consumer configuration specifies:
|
|
8
|
+
|
|
9
|
+
- **Data sources** (which producers to consume from)
|
|
10
|
+
- **Field selection** (which fields to include and how to transform them)
|
|
11
|
+
- **Filtering** (how to subset the data)
|
|
12
|
+
- **Transformation** (how data is changed)
|
|
13
|
+
- **Output formats** (where, when and how to export the processed data)
|
|
14
|
+
|
|
15
|
+
## **Core Components**
|
|
16
|
+
|
|
17
|
+
### **Consumer Structure**
|
|
18
|
+
|
|
19
|
+
All consumers follow this basic structure:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
|
|
24
|
+
"name": "consumer-name",
|
|
25
|
+
"description": "Consumer description",
|
|
26
|
+
"producers": [/* data sources */ ],
|
|
27
|
+
"fields": [/* field selection and transformation */ ],
|
|
28
|
+
"outputs": [/* export configurations */ ],
|
|
29
|
+
"filters": [/* data filtering */ ],
|
|
30
|
+
"options": {/* processing options */ },
|
|
31
|
+
"validate": [/* dataset-level validations */ ],
|
|
32
|
+
"split": {/* break the output into several files */ },
|
|
33
|
+
"metadata": {/* custom tags */ },
|
|
34
|
+
"project": "project-name",
|
|
35
|
+
"schema": "schema-name",
|
|
36
|
+
"_version": 1
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### **Consumer Types by Use Case**
|
|
41
|
+
|
|
42
|
+
| Use Case | Description | Key Features |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| **Simple Extraction** | Basic field selection from one producer | Field selection, aliasing, transformations |
|
|
45
|
+
| **Data Joining** | Combine data from multiple producers | JOIN operations, relationship definitions |
|
|
46
|
+
| **Data Union** | Merge datasets with same structure | UNION operations, data consolidation |
|
|
47
|
+
| **Analytics** | Complex transformations and aggregations | Calculated fields, filtering, pivots |
|
|
48
|
+
| **Export Pipeline** | Automated data export with scheduling | Triggers, multiple output formats |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## **Basic Consumer Configuration**
|
|
53
|
+
|
|
54
|
+
### **Simple Field Selection**
|
|
55
|
+
|
|
56
|
+
The most basic consumer selects all fields from a single producer:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
|
|
61
|
+
"name": "simple-consumer",
|
|
62
|
+
"description": "Basic field selection",
|
|
63
|
+
"producers": [
|
|
64
|
+
{ "name": "producer-name" }
|
|
65
|
+
],
|
|
66
|
+
"fields": [
|
|
67
|
+
{ "key": "*" }
|
|
68
|
+
],
|
|
69
|
+
"outputs": [
|
|
70
|
+
{ "format": "JSON", "exportDestination": "destination" }
|
|
71
|
+
],
|
|
72
|
+
"_version": 1
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### **Selective Field Configuration**
|
|
77
|
+
|
|
78
|
+
Select specific fields with aliases and transformations:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
|
|
83
|
+
"name": "selective-consumer",
|
|
84
|
+
"description": "Selective field extraction with aliases",
|
|
85
|
+
"producers": [
|
|
86
|
+
{ "name": "customer-data" }
|
|
87
|
+
],
|
|
88
|
+
"fields": [
|
|
89
|
+
{ "key": "customer_id", "alias": "Customer ID" },
|
|
90
|
+
{ "key": "first_name", "alias": "First Name" },
|
|
91
|
+
{ "key": "email", "alias": "Email Address" },
|
|
92
|
+
{ "key": "registration_date", "alias": "Registration Date" }
|
|
93
|
+
],
|
|
94
|
+
"outputs": [
|
|
95
|
+
{ "format": "JSON", "exportDestination": "local-output" }
|
|
96
|
+
],
|
|
97
|
+
"_version": 1
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## **Producer Configuration**
|
|
104
|
+
|
|
105
|
+
Producers define the data sources that consumers pull from. Each consumer can have multiple producers for joining or union operations.
|
|
106
|
+
|
|
107
|
+
### **Producer Properties**
|
|
108
|
+
|
|
109
|
+
| Property | Required | Description | Example |
|
|
110
|
+
| --- | --- | --- | --- |
|
|
111
|
+
| `name` | ✅ | Producer **or consumer** name to consume from (see *Consumer as Input*) | `"customer-data"`, `"order-history"` |
|
|
112
|
+
| `joins` | ❌ | JOIN configurations for multi-producer consumers | Array of join definitions |
|
|
113
|
+
| `union` | ❌ | Union data instead of joining | `true`, `false` (default) |
|
|
114
|
+
| `isOptional` | ❌ | If the producer's data is missing at run time (e.g. the file isn't present), don't fail — create a placeholder dataset and continue | `true`, `false` (default) |
|
|
115
|
+
|
|
116
|
+
### **Single Producer**
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"producers": [
|
|
121
|
+
{ "name": "sales-data" }
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### **Multiple Producers with Joins**
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"producers": [
|
|
131
|
+
{ "name": "customers" },
|
|
132
|
+
{
|
|
133
|
+
"name": "orders",
|
|
134
|
+
"joins": [
|
|
135
|
+
{
|
|
136
|
+
"otherName": "customers",
|
|
137
|
+
"relationship": "many-to-one",
|
|
138
|
+
"sql": "${P.customer_id} = ${customers.customer_id}"
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### **Union Operations**
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"producers": [
|
|
151
|
+
{ "name": "sales-2023", "union": true },
|
|
152
|
+
{ "name": "sales-2024", "union": true }
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### **Consumer as Input**
|
|
158
|
+
|
|
159
|
+
A producer entry can name another **consumer** instead of a producer. That consumer is run first and its **full output** is streamed in as this consumer's input — the way to chain pipelines, where one consumer transforms the output of another.
|
|
160
|
+
|
|
161
|
+
This is different from `code_lookup`, which pulls another consumer's output into a small in-memory `code → value` table. A consumer input is the whole dataset: it is materialized to disk and chunked across workers, so the upstream output can be arbitrarily large.
|
|
162
|
+
|
|
163
|
+
The upstream consumer's **output fields** (final names / aliases, not its underlying source columns) become the available columns. Its records are combined with any other producers via union, then shaped by this consumer's own fields, filters, and transforms. Chains are supported (a consumer input whose upstream also has consumer inputs), with cycle and depth detection.
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"producers": [
|
|
168
|
+
{ "name": "c_active_accounts" }
|
|
169
|
+
],
|
|
170
|
+
"fields": [
|
|
171
|
+
{ "key": "id" },
|
|
172
|
+
{ "key": "country", "alias": "country_upper", "transform": { "to_uppercase": true } }
|
|
173
|
+
],
|
|
174
|
+
"filters": [
|
|
175
|
+
{ "rule": { "member": "plan", "operator": "equals", "values": ["enterprise"] } }
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Here `c_active_accounts` is another consumer; `c_active_accounts` runs, and this consumer reads its output, keeps enterprise rows, and uppercases `country`.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## **Field Configuration**
|
|
185
|
+
|
|
186
|
+
Fields define which data columns to include in the consumer output and how to transform them.
|
|
187
|
+
|
|
188
|
+
### **Field Properties**
|
|
189
|
+
|
|
190
|
+
| Property | Required | Description | Example |
|
|
191
|
+
| --- | --- | --- | --- |
|
|
192
|
+
| `key` | ✅ | Field name from producer, `*` for all, or a pattern holding a `*` (see [Pattern Keys](#pattern-keys)) | `"customer_id"`, `"*"`, `"addr*Street"` |
|
|
193
|
+
| `description` | ❌ | Optional human-readable description of the field. No runtime effect, but it is shown under the field in `remora ui-preview` and the field filter matches it | Descriptive text |
|
|
194
|
+
| `alias` | ❌ | New name for the field in output. A template when the key is a pattern | `"Customer ID"`, `"street_*"`, `"{key}_iso"` |
|
|
195
|
+
| `from` | ❌ | Producer to get field from (for ambiguous fields) | `"customers"`, `"orders"` |
|
|
196
|
+
| `transform` | ❌ | Data transformations to apply | Transformation objects |
|
|
197
|
+
| `default` | ❌ | Default value if field is missing | `"Unknown"`, `0`, `null` |
|
|
198
|
+
| `fixed` | ❌ | Use default value always (don't look in data) | `true`, `false` |
|
|
199
|
+
| `hidden` | ❌ | Include in processing but exclude from output | `true`, `false` |
|
|
200
|
+
| `onError` | ❌ | Error handling strategy | `"set_default"`, `"skip"`, `"fail"` |
|
|
201
|
+
| `validate` | ❌ | Field-level data-quality rules (see [Field Validation](#field-validation)) | Array of `{ rule, onFail }` |
|
|
202
|
+
| `copyFrom` | ❌ | Add a new field that is a value copy of an earlier field (copies the value *before* that field's transformations) | `"amount"`, `"Sale Amount"` |
|
|
203
|
+
| `lineCount` | ❌ | Populate the field with the 1-based source line number of each record (consistent across parallel workers) | `true`, `false` |
|
|
204
|
+
| `position` | ⚠️ | `FIXED` output only, and required on every visible field of one: the columns this field occupies and how its value is filled out to them (see [FIXED Output](#fixed-output)) | `{ "width": 10, "start": 21 }` |
|
|
205
|
+
|
|
206
|
+
A field's **output name** is its `alias` when it has one and its `key` otherwise. Two fields resolving to the same output name are rejected at `compile`: a record is keyed by that name, so a collision never failed at run time — one field simply overwrote the other.
|
|
207
|
+
|
|
208
|
+
### **Field Selection Patterns**
|
|
209
|
+
|
|
210
|
+
### **All Fields**
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"fields": [
|
|
215
|
+
{ "key": "*" }
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### **Specific Fields**
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"fields": [
|
|
225
|
+
{ "key": "customer_id" },
|
|
226
|
+
{ "key": "first_name" },
|
|
227
|
+
{ "key": "email" }
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### **Fields with Aliases**
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"fields": [
|
|
237
|
+
{ "key": "customer_id", "alias": "Customer ID" },
|
|
238
|
+
{ "key": "first_name", "alias": "First Name" },
|
|
239
|
+
{ "key": "order_total", "alias": "Total Amount" }
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### **Pattern Keys**
|
|
245
|
+
|
|
246
|
+
A key holding a `*` selects **every field whose name matches**, and each match keeps the rest of the field's configuration. One entry then stands in for a whole family of columns — which is what makes the columns a [repeating group](Remora%20-%20Producers.md#repeating-groups-occurs) generates usable without writing one field per occurrence:
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{
|
|
250
|
+
"fields": [
|
|
251
|
+
{ "key": "member_id" },
|
|
252
|
+
{ "key": "addr*Street", "alias": "street_*" },
|
|
253
|
+
{ "key": "addr*State", "alias": "{key}_upper", "transform": { "to_uppercase": true }, "onError": "skip" }
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Against a producer with three address occurrences that yields `street_0`, `street_1`, `street_2`, `addr0State_upper`, `addr1State_upper` and `addr2State_upper` — and the `to_uppercase` transform, declared once, runs on each of the three matches.
|
|
259
|
+
|
|
260
|
+
Since several columns come out of one entry, `alias` becomes a **template** that has to vary per match:
|
|
261
|
+
|
|
262
|
+
| Token | Replaced by | Example (`key: "addr*Street"` matching `addr0Street`) |
|
|
263
|
+
| --- | --- | --- |
|
|
264
|
+
| `*` | What the pattern's wildcard captured, positionally | `"street_*"` gives `street_0` |
|
|
265
|
+
| `{key}` | The full matched field name | `"{key}_iso"` gives `addr0Street_iso` |
|
|
266
|
+
|
|
267
|
+
Notes:
|
|
268
|
+
|
|
269
|
+
- Matching is literal apart from the wildcards — a pattern is never a regular expression.
|
|
270
|
+
- A pattern is expanded **where it is declared**, and its matches follow the producer's own column order.
|
|
271
|
+
- A pattern that matches **nothing** is rejected at `compile`, as is an `alias` that would give two matches the same output name. So is a pattern combined with `fixed`, `copyFrom` or `lineCount`: those fields don't read a producer column, so there is nothing to match.
|
|
272
|
+
- Add `"onError": "skip"` when a transform can't handle every match — over a sparse repeating group most occurrences are `null`, and a transform like `to_uppercase` rejects a non-string value rather than passing it through.
|
|
273
|
+
- The bare `"key": "*"` keeps its own meaning: every column of one producer, under its own name.
|
|
274
|
+
|
|
275
|
+
### **Fields from Specific Producers**
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"fields": [
|
|
280
|
+
{ "key": "customer_id", "from": "customers" },
|
|
281
|
+
{ "key": "order_date", "from": "orders" },
|
|
282
|
+
{ "key": "product_name", "from": "products" }
|
|
283
|
+
]
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### **Fixed Fields**
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
{
|
|
291
|
+
"fields": [
|
|
292
|
+
{ "key": "report_date", "default": "2024-01-01", "fixed": true },
|
|
293
|
+
{ "key": "version", "default": "1.0", "fixed": true }
|
|
294
|
+
]
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### **Copied Fields**
|
|
299
|
+
|
|
300
|
+
`copyFrom` adds a new field whose value is a copy of another field that appears **earlier** in the list. The value is copied *before* the source field's own transformations are applied, so the two fields can then diverge. Reference the source by its `alias` if it has one, otherwise its `key`.
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
{
|
|
304
|
+
"fields": [
|
|
305
|
+
{ "key": "amount", "alias": "raw_amount" },
|
|
306
|
+
{ "key": "amount", "alias": "amount_with_tax", "copyFrom": "raw_amount", "transform": { "multiply": 1.08 } }
|
|
307
|
+
]
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### **Line Number Fields**
|
|
312
|
+
|
|
313
|
+
Set `lineCount` to populate a field with the 1-based source line number of each record. The value stays consistent even when a file is processed in parallel across multiple workers.
|
|
314
|
+
|
|
315
|
+
```json
|
|
316
|
+
{
|
|
317
|
+
"fields": [
|
|
318
|
+
{ "key": "row_number", "lineCount": true },
|
|
319
|
+
{ "key": "customer_id" }
|
|
320
|
+
]
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## **Field Transformations**
|
|
327
|
+
|
|
328
|
+
Transformations allow you to modify field values during processing.
|
|
329
|
+
|
|
330
|
+
### **Data Type Transformations**
|
|
331
|
+
|
|
332
|
+
### **Cast Operations**
|
|
333
|
+
|
|
334
|
+
```json
|
|
335
|
+
{
|
|
336
|
+
"key": "birth_date",
|
|
337
|
+
"transform": { "cast": "datetime", "format": "YYYY-MM-DD" }
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
```json
|
|
342
|
+
{
|
|
343
|
+
"key": "price",
|
|
344
|
+
"transform": { "cast": "number" }
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### **String Transformations**
|
|
349
|
+
|
|
350
|
+
### **Case Conversions**
|
|
351
|
+
|
|
352
|
+
```json
|
|
353
|
+
{
|
|
354
|
+
"key": "name",
|
|
355
|
+
"transform": { "to_uppercase": true }
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
{
|
|
361
|
+
"key": "email",
|
|
362
|
+
"transform": { "to_lowercase": true }
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
```json
|
|
367
|
+
{
|
|
368
|
+
"key": "title",
|
|
369
|
+
"transform": { "capitalize": true }
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### **String Manipulation**
|
|
374
|
+
|
|
375
|
+
```json
|
|
376
|
+
{
|
|
377
|
+
"key": "description",
|
|
378
|
+
"transform": { "trim": true }
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
```json
|
|
383
|
+
{
|
|
384
|
+
"key": "year",
|
|
385
|
+
"transform": { "substring": { "start": 0, "end": 4 } }
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
```json
|
|
390
|
+
{
|
|
391
|
+
"key": "code",
|
|
392
|
+
"transform": { "pad_start": { "length": 6, "char": "0" } }
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
```json
|
|
397
|
+
{
|
|
398
|
+
"key": "name",
|
|
399
|
+
"transform": { "prepend": "Mr. " }
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
```json
|
|
404
|
+
{
|
|
405
|
+
"key": "filename",
|
|
406
|
+
"transform": { "append": ".txt" }
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### **String Splitting**
|
|
411
|
+
|
|
412
|
+
```json
|
|
413
|
+
{
|
|
414
|
+
"key": "full_name",
|
|
415
|
+
"alias": "first_name",
|
|
416
|
+
"transform": { "split": { "separator": " ", "index": 0 } }
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### **Numeric Transformations**
|
|
421
|
+
|
|
422
|
+
### **Mathematical Operations**
|
|
423
|
+
|
|
424
|
+
```json
|
|
425
|
+
{
|
|
426
|
+
"key": "price",
|
|
427
|
+
"alias": "price_with_tax",
|
|
428
|
+
"transform": { "multiply": 1.08 }
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```json
|
|
433
|
+
{
|
|
434
|
+
"key": "base_score",
|
|
435
|
+
"alias": "adjusted_score",
|
|
436
|
+
"transform": { "add": 10 }
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### **Referencing Other Fields**
|
|
441
|
+
|
|
442
|
+
`multiply`, `add`, `prepend`, `append`, and the `then`/`else`/`default` of `conditional` and `switch_case` accept a `"$fieldName"` string instead of a literal — this resolves to the current value of another field in the same record (matched by its alias if set, otherwise its key). Use `"$$"` to escape a literal leading `$`.
|
|
443
|
+
|
|
444
|
+
```json
|
|
445
|
+
{
|
|
446
|
+
"key": "net_amount",
|
|
447
|
+
"alias": "total",
|
|
448
|
+
"transform": { "add": "$tax_amount" }
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
The referenced field must be **present** in the record: if it isn't, the transformation fails and the field's `onError` policy applies. A referenced field that is present but `null` is treated as real data and passed to the operation rather than failing the row — in `multiply`/`add` it counts as `0`, and in `prepend`/`append` it concatenates as the text `null`.
|
|
453
|
+
|
|
454
|
+
### **Multiplying / Adding Multiple Fields**
|
|
455
|
+
|
|
456
|
+
`multiplyBy` and `addBy` combine several fields together (rather than a single factor/addend):
|
|
457
|
+
|
|
458
|
+
```json
|
|
459
|
+
{
|
|
460
|
+
"key": "line_total",
|
|
461
|
+
"transform": { "multiplyBy": { "fields": ["quantity", "unit_price"] } }
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
```json
|
|
466
|
+
{
|
|
467
|
+
"key": "grand_total",
|
|
468
|
+
"transform": { "addBy": { "fields": ["subtotal", "tax", "shipping"] } }
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### **Date/Time Transformations**
|
|
473
|
+
|
|
474
|
+
### **Date Component Extraction**
|
|
475
|
+
|
|
476
|
+
```json
|
|
477
|
+
{
|
|
478
|
+
"key": "order_date",
|
|
479
|
+
"alias": "order_year",
|
|
480
|
+
"transform": { "extract": "year" }
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
```json
|
|
485
|
+
{
|
|
486
|
+
"key": "timestamp",
|
|
487
|
+
"alias": "hour_of_day",
|
|
488
|
+
"transform": { "extract": "hour" }
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
`extract` supports `year`, `month`, `day`, `hour`, and `minute`.
|
|
493
|
+
|
|
494
|
+
### **Date Reformatting**
|
|
495
|
+
|
|
496
|
+
`date_format` reformats a date string from one representation to another using [dayjs](https://day.js.org/docs/en/display/format) tokens — distinct from `cast`, which only parses or formats with a single token set. `strict` parsing defaults to `true`.
|
|
497
|
+
|
|
498
|
+
```json
|
|
499
|
+
{
|
|
500
|
+
"key": "birth_date",
|
|
501
|
+
"transform": { "date_format": { "input_fmt": "DDMMYYYY", "output_fmt": "YYYY-MM-DD" } }
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
**Day-of-year ("Julian") dates** — common in mainframe flat files, where a date is written as a year plus the ordinal day within it — are read with `DDD` or `DDDD`, always 3 digits:
|
|
506
|
+
|
|
507
|
+
```json
|
|
508
|
+
{
|
|
509
|
+
"key": "service_date",
|
|
510
|
+
"transform": { "date_format": { "input_fmt": "YYYYDDD", "output_fmt": "YYYY-MM-DD" } }
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
`2007305` reads as `2007-11-01`. A day-of-year format combines with `YYYY`/`YY`, `HH`, `mm`, `ss`, `SSS` and literal separators, but not with `MM`/`DD` — a date can't declare both an ordinal day and a calendar one. The same tokens work in a dimension's `format` on the producer side.
|
|
515
|
+
|
|
516
|
+
A format built on a token dayjs can only *write* and not read (`Do`, `Q`, `w`, `W`, `k`, `gggg`, `z`) is **rejected** with an error naming the token, rather than parsed loosely into a plausible wrong date.
|
|
517
|
+
|
|
518
|
+
### **Regular Expression Transformations**
|
|
519
|
+
|
|
520
|
+
### **Pattern Matching**
|
|
521
|
+
|
|
522
|
+
```json
|
|
523
|
+
{
|
|
524
|
+
"key": "email",
|
|
525
|
+
"alias": "is_valid_email",
|
|
526
|
+
"transform": { "regex_match": { "pattern": "^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$" } }
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### **Pattern Replacement**
|
|
531
|
+
|
|
532
|
+
```json
|
|
533
|
+
{
|
|
534
|
+
"key": "phone",
|
|
535
|
+
"alias": "formatted_phone",
|
|
536
|
+
"transform": { "regex_replace": { "pattern": "(\\d{3})(\\d{3})(\\d{4})", "replacement": "($1) $2-$3" } }
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### **Pattern Extraction**
|
|
541
|
+
|
|
542
|
+
```json
|
|
543
|
+
{
|
|
544
|
+
"key": "address",
|
|
545
|
+
"alias": "zip_code",
|
|
546
|
+
"transform": { "regex_extract": { "pattern": "\\d{5}(-\\d{4})?", "group": 0 } }
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### **Conditional Transformations**
|
|
551
|
+
|
|
552
|
+
### **Simple Conditions**
|
|
553
|
+
|
|
554
|
+
```json
|
|
555
|
+
{
|
|
556
|
+
"key": "age",
|
|
557
|
+
"alias": "age_group",
|
|
558
|
+
"transform": {
|
|
559
|
+
"conditional": {
|
|
560
|
+
"clauses": [
|
|
561
|
+
{ "if": { "less_than": 18 }, "then": "Minor" },
|
|
562
|
+
{ "if": { "less_than": 65 }, "then": "Adult" }
|
|
563
|
+
],
|
|
564
|
+
"else": "Senior"
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### **Complex Conditions**
|
|
571
|
+
|
|
572
|
+
```json
|
|
573
|
+
{
|
|
574
|
+
"key": "score",
|
|
575
|
+
"alias": "grade",
|
|
576
|
+
"transform": {
|
|
577
|
+
"conditional": {
|
|
578
|
+
"clauses": [
|
|
579
|
+
{ "if": { "greater_than_or_equal": 90 }, "then": "A" },
|
|
580
|
+
{ "if": { "greater_than_or_equal": 80 }, "then": "B" },
|
|
581
|
+
{ "if": { "greater_than_or_equal": 70 }, "then": "C" },
|
|
582
|
+
{ "if": { "greater_than_or_equal": 60 }, "then": "D" }
|
|
583
|
+
],
|
|
584
|
+
"else": "F"
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Clauses are evaluated in order and the first match wins; `else` is optional (if omitted, the original value is kept). The `if` condition supports these operators:
|
|
591
|
+
|
|
592
|
+
| Operator | Meaning |
|
|
593
|
+
| --- | --- |
|
|
594
|
+
| `greater_than`, `greater_than_or_equal` | Numeric `>` / `>=` |
|
|
595
|
+
| `less_than`, `less_than_or_equal` | Numeric `<` / `<=` |
|
|
596
|
+
| `equals`, `not_equals` | Strict (in)equality |
|
|
597
|
+
| `in`, `not_in` | Value (not) in a list |
|
|
598
|
+
| `starts_with`, `ends_with` | String prefix / suffix |
|
|
599
|
+
| `contains`, `not_contains` | String (not) containing a substring |
|
|
600
|
+
| `is_empty`, `is_not_empty` | Value is (not) null/undefined/blank |
|
|
601
|
+
|
|
602
|
+
### **Value Mapping (switch_case)**
|
|
603
|
+
|
|
604
|
+
`switch_case` maps specific input values to outputs by strict equality (evaluated in order, first match wins). `default` is used when no case matches (optional — otherwise the original value is kept).
|
|
605
|
+
|
|
606
|
+
```json
|
|
607
|
+
{
|
|
608
|
+
"key": "status_code",
|
|
609
|
+
"alias": "status",
|
|
610
|
+
"transform": {
|
|
611
|
+
"switch_case": {
|
|
612
|
+
"cases": [
|
|
613
|
+
{ "when": "A", "then": "Active" },
|
|
614
|
+
{ "when": "I", "then": "Inactive" }
|
|
615
|
+
],
|
|
616
|
+
"default": "Unknown"
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### **Masking**
|
|
623
|
+
|
|
624
|
+
`mask` obfuscates a value for data privacy. Options: `hash` (one-way hash), `mask` (replace with asterisks), `crypt` (reversible encryption), `random` (random value), `seeded-random` (consistent random value per input), `none`.
|
|
625
|
+
|
|
626
|
+
```json
|
|
627
|
+
{
|
|
628
|
+
"key": "ssn",
|
|
629
|
+
"transform": { "mask": "hash" }
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### **Bit Extraction**
|
|
634
|
+
|
|
635
|
+
`bit_extract` reads a single bit from an integer bitmask (e.g. decoding a packed flag byte). `position` is a 0-based index from the least-significant bit; set `as_boolean` to return `true`/`false` instead of `1`/`0`.
|
|
636
|
+
|
|
637
|
+
```json
|
|
638
|
+
{
|
|
639
|
+
"key": "flags",
|
|
640
|
+
"alias": "is_active",
|
|
641
|
+
"transform": { "bit_extract": { "position": 0, "as_boolean": true } }
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### **Code Lookup**
|
|
646
|
+
|
|
647
|
+
`code_lookup` maps a code to a value using the output of **another consumer** as the lookup table. The referenced consumer is executed to build a `code → value` table (keep it small and cheap). If the code isn't found and no `default` is given, the field's `onError` policy applies.
|
|
648
|
+
|
|
649
|
+
```json
|
|
650
|
+
{
|
|
651
|
+
"key": "plan_code",
|
|
652
|
+
"alias": "plan_tier",
|
|
653
|
+
"transform": {
|
|
654
|
+
"code_lookup": {
|
|
655
|
+
"consumer": "c_lookup_plans",
|
|
656
|
+
"key": "plan_code",
|
|
657
|
+
"value": "plan_tier",
|
|
658
|
+
"default": "Unknown Tier"
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### **Array Concatenation**
|
|
665
|
+
|
|
666
|
+
`concat` joins the elements of an array-valued field into a single string.
|
|
667
|
+
|
|
668
|
+
```json
|
|
669
|
+
{
|
|
670
|
+
"key": "tags",
|
|
671
|
+
"transform": { "concat": { "separator": ", " } }
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### **Field Combination**
|
|
676
|
+
|
|
677
|
+
### **Combining Multiple Fields**
|
|
678
|
+
|
|
679
|
+
```json
|
|
680
|
+
{
|
|
681
|
+
"key": "full_name",
|
|
682
|
+
"transform": {
|
|
683
|
+
"combine_fields": {
|
|
684
|
+
"fields": ["first_name", "last_name"],
|
|
685
|
+
"separator": " "
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
```json
|
|
692
|
+
{
|
|
693
|
+
"key": "address_line",
|
|
694
|
+
"transform": {
|
|
695
|
+
"combine_fields": {
|
|
696
|
+
"fields": ["street", "city", "state"],
|
|
697
|
+
"template": "{street}, {city}, {state}"
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
### **Chained Transformations**
|
|
704
|
+
|
|
705
|
+
Apply multiple transformations in sequence:
|
|
706
|
+
|
|
707
|
+
```json
|
|
708
|
+
{
|
|
709
|
+
"key": "name",
|
|
710
|
+
"alias": "formatted_name",
|
|
711
|
+
"transform": [
|
|
712
|
+
{ "trim": true },
|
|
713
|
+
{ "to_lowercase": true },
|
|
714
|
+
{ "capitalize": true }
|
|
715
|
+
]
|
|
716
|
+
}
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
---
|
|
720
|
+
|
|
721
|
+
## **Field Validation**
|
|
722
|
+
|
|
723
|
+
Attach data-quality rules to a field with `validate`. Each field takes an ordered array of `{ rule, onFail }` entries; every value is checked against the rules and the `onFail` action decides what happens when a rule fails.
|
|
724
|
+
|
|
725
|
+
**Rules:** `min`, `max` (numeric bounds), `min_length`, `max_length` (string length), `regex` (pattern match), `required` (value must be present), `in` / `not_in` (allowed / disallowed value lists).
|
|
726
|
+
|
|
727
|
+
**`onFail` actions:** `fail` (abort the run), `skip` (drop the record), `warn` (log and keep), `set_default` (replace with the field's `default`).
|
|
728
|
+
|
|
729
|
+
```json
|
|
730
|
+
{
|
|
731
|
+
"fields": [
|
|
732
|
+
{
|
|
733
|
+
"key": "email",
|
|
734
|
+
"validate": [
|
|
735
|
+
{ "rule": { "required": true }, "onFail": "skip" },
|
|
736
|
+
{ "rule": { "regex": "^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$" }, "onFail": "warn" }
|
|
737
|
+
]
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
"key": "age",
|
|
741
|
+
"default": 0,
|
|
742
|
+
"validate": [
|
|
743
|
+
{ "rule": { "min": 0 }, "onFail": "set_default" },
|
|
744
|
+
{ "rule": { "max": 120 }, "onFail": "set_default" }
|
|
745
|
+
]
|
|
746
|
+
}
|
|
747
|
+
]
|
|
748
|
+
}
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
---
|
|
752
|
+
|
|
753
|
+
## **Data Filtering**
|
|
754
|
+
|
|
755
|
+
Filters subset the consumer's result set before it is exported.
|
|
756
|
+
|
|
757
|
+
### **Rule-Based Filters**
|
|
758
|
+
|
|
759
|
+
Filters are applied per record to the consumer result set, after the producers have been read and the
|
|
760
|
+
fields shaped. They work the same way for every source engine — SQL or file.
|
|
761
|
+
|
|
762
|
+
```json
|
|
763
|
+
{
|
|
764
|
+
"filters": [
|
|
765
|
+
{
|
|
766
|
+
"rule": {
|
|
767
|
+
"member": "amount",
|
|
768
|
+
"operator": "greaterThan",
|
|
769
|
+
"values": ["1000"]
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
"rule": {
|
|
774
|
+
"member": "status",
|
|
775
|
+
"operator": "equals",
|
|
776
|
+
"values": ["Active"]
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
]
|
|
780
|
+
}
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
### **Filter Operators**
|
|
784
|
+
|
|
785
|
+
| Operator | Description | Example |
|
|
786
|
+
| --- | --- | --- |
|
|
787
|
+
| `equals` | Exact match | `{"operator": "equals", "values": ["Active"]}` |
|
|
788
|
+
| `notEquals` | Not equal to | `{"operator": "notEquals", "values": ["Inactive"]}` |
|
|
789
|
+
| `contains` | String contains | `{"operator": "contains", "values": ["test"]}` |
|
|
790
|
+
| `notContains` | String doesn't contain | `{"operator": "notContains", "values": ["temp"]}` |
|
|
791
|
+
| `startsWith` | String starts with | `{"operator": "startsWith", "values": ["Mr."]}` |
|
|
792
|
+
| `endsWith` | String ends with | `{"operator": "endsWith", "values": [".com"]}` |
|
|
793
|
+
| `greaterThan` | Greater than | `{"operator": "greaterThan", "values": ["100"]}` |
|
|
794
|
+
| `greaterThanOrEquals` | Greater than or equal | `{"operator": "greaterThanOrEquals", "values": ["100"]}` |
|
|
795
|
+
| `lessThan` | Less than | `{"operator": "lessThan", "values": ["1000"]}` |
|
|
796
|
+
| `lessThanOrEquals` | Less than or equal | `{"operator": "lessThanOrEquals", "values": ["1000"]}` |
|
|
797
|
+
| `in` | Value in list | `{"operator": "in", "values": ["A", "B", "C"]}` |
|
|
798
|
+
| `notIn` | Value not in list | `{"operator": "notIn", "values": ["X", "Y", "Z"]}` |
|
|
799
|
+
| `between` | Value between range | `{"operator": "between", "values": ["100", "1000"]}` |
|
|
800
|
+
| `notBetween` | Value outside range | `{"operator": "notBetween", "values": ["100", "1000"]}` |
|
|
801
|
+
| `isNull` | Value is null | `{"operator": "isNull", "values": []}` |
|
|
802
|
+
| `isNotNull` | Value is not null | `{"operator": "isNotNull", "values": []}` |
|
|
803
|
+
| `true` | Boolean true | `{"operator": "true", "values": []}` |
|
|
804
|
+
| `false` | Boolean false | `{"operator": "false", "values": []}` |
|
|
805
|
+
| `matches` | Matches a regular expression | `{"operator": "matches", "values": ["^A.*Z$"]}` |
|
|
806
|
+
| `notMatches` | Does not match a regular expression | `{"operator": "notMatches", "values": ["^tmp"]}` |
|
|
807
|
+
|
|
808
|
+
### **How Values Are Compared**
|
|
809
|
+
|
|
810
|
+
A filter's `values` are always written as strings, while a record holds the value its producer
|
|
811
|
+
dimension was cast to — a number for a `number` dimension, a boolean for a `boolean` one, a UTC ISO
|
|
812
|
+
timestamp for a `datetime` one. Each side is read as the type the record holds before being compared,
|
|
813
|
+
so the string in the configuration is not what makes the match fail:
|
|
814
|
+
|
|
815
|
+
- **Numbers** — `{"member": "amount", "operator": "equals", "values": ["150"]}` matches an `amount`
|
|
816
|
+
of `150`. A value that is not a number (`"abc"`) matches nothing.
|
|
817
|
+
- **Booleans** — `"true"`, `"false"`, `"1"` and `"0"` all match a `boolean` dimension. The dedicated
|
|
818
|
+
`true`/`false` operators take no values and do the same thing.
|
|
819
|
+
- **Dates** — a `datetime` is compared as an **instant**, not as text, so `"2024-03-01"` matches a
|
|
820
|
+
record holding `2024-03-01T00:00:00.000Z`, and a bound may carry its own offset
|
|
821
|
+
(`"2024-03-01T11:30:00+02:00"`). A bound with no offset is read as **UTC**, so a filter means the
|
|
822
|
+
same thing whatever the host's timezone. Only ISO-8601 bounds (`2024-03-01`, `2024-03-01T09:00:00`,
|
|
823
|
+
`2024-03-01T09:00:00Z`) are read as dates; anything else is compared as text. A `datetime`
|
|
824
|
+
dimension always holds an ISO timestamp whatever `format` it was read with, so filter it with an
|
|
825
|
+
ISO bound — `"20240301"` compares as text and matches nothing. A date kept as a `string` dimension
|
|
826
|
+
is text on both sides, and matches the spelling the column actually holds.
|
|
827
|
+
- A bound naming a **day** means that day's first instant, exactly as a SQL date literal does:
|
|
828
|
+
`{"operator": "between", "values": ["2024-03-01", "2024-03-31"]}` stops at the start of the 31st.
|
|
829
|
+
Write `"2024-03-31T23:59:59"` to include it.
|
|
830
|
+
- **Text operators** (`contains`, `startsWith`, `endsWith`, `matches`) read a non-string value's
|
|
831
|
+
text form — a number's digits, a date's ISO timestamp.
|
|
832
|
+
- A record whose value is **null** matches no comparison: `isNull`/`isNotNull` are how you filter on
|
|
833
|
+
it. The negations (`notEquals`, `notIn`, `notContains`, `notMatches`) keep such a record.
|
|
834
|
+
|
|
835
|
+
### **Run Options (API and queue invocations)**
|
|
836
|
+
|
|
837
|
+
A consumer run started by the execute-consumer API or by the project's `API_QUEUE` may carry an
|
|
838
|
+
`options` object asking for **more filters** and a **record limit** on top of what the consumer
|
|
839
|
+
already declares. The invocation's filters are applied alongside the consumer's own, and the limit
|
|
840
|
+
caps the output records exactly as `remora run --limit` does.
|
|
841
|
+
|
|
842
|
+
```json
|
|
843
|
+
{
|
|
844
|
+
"consumer": "c_sales_export",
|
|
845
|
+
"options": {
|
|
846
|
+
"limit": 100,
|
|
847
|
+
"filters": [
|
|
848
|
+
{
|
|
849
|
+
"rule": {
|
|
850
|
+
"member": "region",
|
|
851
|
+
"operator": "in",
|
|
852
|
+
"values": ["EMEA", "APAC"]
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
]
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
The options are checked against the named consumer **before anything is read from a source**, so a
|
|
861
|
+
malformed request costs nothing and is answered by the caller's own mistake rather than by a failure
|
|
862
|
+
from the middle of a run. The HTTP API answers **400** with the reason; a queue command fails and
|
|
863
|
+
follows the `deadLetterQueue` rule for the queue it arrived on.
|
|
864
|
+
|
|
865
|
+
| Option | Rule |
|
|
866
|
+
| --- | --- |
|
|
867
|
+
| `filters` | An array of filter objects. At most 100 filters, nested through `and` / `or` at most 5 levels deep |
|
|
868
|
+
| `filters[].rule.member` | Must name a field the consumer actually **outputs** — the final name after `alias`, which is what a record holds when filters run. An unknown name is refused rather than silently matching nothing |
|
|
869
|
+
| `filters[].rule.operator` | One of the [filter operators](#filter-operators) |
|
|
870
|
+
| `filters[].rule.values` | An array of strings, of the arity the operator needs: `between` exactly two, `in` / `notIn` at least one, `isNull` / `isNotNull` / `true` / `false` none |
|
|
871
|
+
| `limit` | A whole number from 1 to 10 000 000 — a number, not a string |
|
|
872
|
+
|
|
873
|
+
Every rejection names the option and where in the tree it sits, for example
|
|
874
|
+
`Invalid filter at filters[1].or[0]: consumer "c_sales" has no field named "regionn"`.
|
|
875
|
+
|
|
876
|
+
**Refused outright:**
|
|
877
|
+
|
|
878
|
+
- `offset` and `order` — no stage of a run implements them, so accepting them would mean answering
|
|
879
|
+
with unskipped, unordered data as though the request had been honoured
|
|
880
|
+
- the `sql` filter operator, which is not evaluated against records
|
|
881
|
+
- any other key. An unknown option (`limits`, `filter`) is a typo that would otherwise be ignored in
|
|
882
|
+
silence, so it is refused instead
|
|
883
|
+
|
|
884
|
+
### **Pushdown (Delta Share)**
|
|
885
|
+
|
|
886
|
+
Filters are always applied per record, but a source that can narrow the read for itself is *also*
|
|
887
|
+
given them as a query hint, so it hands back less than the whole table. Today that is **Delta
|
|
888
|
+
Share**: the eligible filters go out as `jsonPredicateHints` on the table query and the record limit
|
|
889
|
+
as `limitHint`, and the share server skips the files that cannot hold a matching row or that fall
|
|
890
|
+
past the rows being asked for. Nothing about the result changes — the per-record pass runs either
|
|
891
|
+
way — so this only ever saves download time and staging space.
|
|
892
|
+
|
|
893
|
+
A filter is pushed only when it can mean exactly the same thing at the source:
|
|
894
|
+
|
|
895
|
+
- The field reads a producer dimension **untouched**. A field with a `transform`, a `validate`, a
|
|
896
|
+
`default`, or one that is `fixed`/`copyFrom`/`lineCount`, is never pushed, and neither is a masked
|
|
897
|
+
dimension or one filled in after the read (`sourceFilename`).
|
|
898
|
+
- The operator has a counterpart in the Delta Sharing predicate grammar: `equals`, `in`, `between`
|
|
899
|
+
and the four `greaterThan`/`lessThan` comparisons. The substring and regex operators
|
|
900
|
+
(`contains`, `startsWith`, `matches`, …) have none and stay local.
|
|
901
|
+
- The filter does not also keep rows whose value is **missing**. A value the source doesn't have
|
|
902
|
+
reads back as `""`/`0`, so `notEquals`, `isNotNull`, `notBetween` and any range covering `0`
|
|
903
|
+
keep those rows — a source pruning on them would drop data — and none of them is pushed.
|
|
904
|
+
- Date bounds are ISO-8601 (`2024-01-01`, or `2024-01-01T00:00:00Z`). A date in a producer's own
|
|
905
|
+
`format` is not translated, and a bound carrying milliseconds is left local rather than rounded.
|
|
906
|
+
- Filters supplied by the invocation (an API or queue request) are pushed on the same terms as the
|
|
907
|
+
ones written in the consumer.
|
|
908
|
+
|
|
909
|
+
Boolean columns are never pushed.
|
|
910
|
+
|
|
911
|
+
The **record limit** (`remora run -l`, or `limit` on an API/queue request) is pushed on a stricter
|
|
912
|
+
rule, because a bounded read is only sound when every row read reaches the output. It is dropped
|
|
913
|
+
whenever anything between the source and the export can *drop* a record: any filter at all (a
|
|
914
|
+
bounded read could have fetched exactly the rows the filter rejects), `distinct`, `distinctOn`,
|
|
915
|
+
`pivot`, a field validation set to `"onFail": "skip"`, a producer `recordSelector`, or a group that
|
|
916
|
+
fans a line out to rows. A transformation's own `"onError": "skip"` does *not* — it abandons that one
|
|
917
|
+
transformation, not the record.
|
|
918
|
+
|
|
919
|
+
Set `"disablePushdown": true` in a **producer's** settings to turn both hints off for that producer —
|
|
920
|
+
the filters and the limit keep working, the whole table is just read first.
|
|
921
|
+
|
|
922
|
+
### **Complex Filter Examples**
|
|
923
|
+
|
|
924
|
+
### **Multiple Conditions**
|
|
925
|
+
|
|
926
|
+
```json
|
|
927
|
+
{
|
|
928
|
+
"filters": [
|
|
929
|
+
{
|
|
930
|
+
"rule": {
|
|
931
|
+
"member": "amount",
|
|
932
|
+
"operator": "greaterThan",
|
|
933
|
+
"values": ["100"]
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
"rule": {
|
|
938
|
+
"member": "status",
|
|
939
|
+
"operator": "in",
|
|
940
|
+
"values": ["Active", "Pending"]
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
]
|
|
944
|
+
}
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
### **Date Range Filtering**
|
|
948
|
+
|
|
949
|
+
```json
|
|
950
|
+
{
|
|
951
|
+
"filters": [
|
|
952
|
+
{
|
|
953
|
+
"rule": {
|
|
954
|
+
"member": "order_date",
|
|
955
|
+
"operator": "between",
|
|
956
|
+
"values": ["2024-01-01", "2024-12-31"]
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
]
|
|
960
|
+
}
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
---
|
|
964
|
+
|
|
965
|
+
## **Output Configuration**
|
|
966
|
+
|
|
967
|
+
Outputs define where and how the processed data is exported. A consumer declares **exactly one** —
|
|
968
|
+
`outputs` stays an array, but a second entry is refused at `compile`. A run stages its records in one
|
|
969
|
+
encoding and *moves* that file to the destination, so there has never been anything left for a second
|
|
970
|
+
output to write: only the first was ever exported. To write the same data twice, declare a second
|
|
971
|
+
consumer over the same producers.
|
|
972
|
+
|
|
973
|
+
### **Output Properties**
|
|
974
|
+
|
|
975
|
+
| Property | Required | Description | Example |
|
|
976
|
+
| --- | --- | --- | --- |
|
|
977
|
+
| `format` | ✅ | Output format | `"JSON"`, `"CSV"`, `"TXT"`, `"FIXED"`, `"PARQUET"`, `"API"` |
|
|
978
|
+
| `exportDestination` | ❌ | Target destination source | `"s3-bucket"`, `"local-output"` |
|
|
979
|
+
| `exportName` | ❌ | Fixed filename for exports | `"daily-report"`, `"customer-data"` |
|
|
980
|
+
| `delimiter` | ❌ | Field delimiter, only used for `TXT` output (defaults to a tab). Ignored by every other format, `PARQUET` included, and rejected on `FIXED`, which has no delimiter at all | `"\t"`, `";"` |
|
|
981
|
+
| `header` | ❌ | `CSV`/`TXT` only: write the field names as the first line of each exported file (see [The Header Line](#the-header-line)). Rejected on `PARQUET`, which carries its own schema, and on `FIXED`, which has no room for one | `true` (default), `false` |
|
|
982
|
+
| `omitNull` | ❌ | `JSON`/`API` only: leave out the fields whose value is `null` (see [Omitting Null Fields](#omitting-null-fields)) | `true`, `false` (default) |
|
|
983
|
+
| `recordLength` | ❌ | `FIXED` only: the declared length of one record, which `compile` checks the field widths sum to (see [FIXED Output](#fixed-output)) | `196` |
|
|
984
|
+
| `defaults` | ❌ | `FIXED` only: the fill rules every field of one dimension type takes unless its own `position` overrides them | `{ "number": { "pad": "0" } }` |
|
|
985
|
+
| `lineEnding` | ❌ | `FIXED` only: what ends every record, the last one included | `"lf"` (default), `"crlf"` |
|
|
986
|
+
| `layouts` | ❌ | `FIXED` only: several record layouts in one file, picked per record by the value of one field (see [Several Record Layouts in One File](#several-record-layouts-in-one-file)) | Layout map |
|
|
987
|
+
| `trigger` | ❌ | Automated execution triggers | Trigger configuration |
|
|
988
|
+
| `onSuccess` | ❌ | Actions to perform on successful export | Action array |
|
|
989
|
+
| `onError` | ❌ | Actions to perform on export error | Action array |
|
|
990
|
+
|
|
991
|
+
### **Output Formats**
|
|
992
|
+
|
|
993
|
+
### **JSON Output**
|
|
994
|
+
|
|
995
|
+
```json
|
|
996
|
+
{
|
|
997
|
+
"outputs": [
|
|
998
|
+
{
|
|
999
|
+
"format": "JSON",
|
|
1000
|
+
"exportDestination": "s3-bucket",
|
|
1001
|
+
"exportName": "customer-analysis"
|
|
1002
|
+
}
|
|
1003
|
+
]
|
|
1004
|
+
}
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
### **Omitting Null Fields**
|
|
1008
|
+
|
|
1009
|
+
A record normally carries every field, including the ones with no value (`"addr2Street": null`). On a wide, sparse record — a positional layout with 1,300 fields of which a few dozen are populated — that is most of the payload. `omitNull` leaves those keys out entirely:
|
|
1010
|
+
|
|
1011
|
+
```json
|
|
1012
|
+
{
|
|
1013
|
+
"outputs": [
|
|
1014
|
+
{
|
|
1015
|
+
"format": "JSON",
|
|
1016
|
+
"exportDestination": "s3-bucket",
|
|
1017
|
+
"exportName": "member-detail",
|
|
1018
|
+
"omitNull": true
|
|
1019
|
+
}
|
|
1020
|
+
]
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Records then legitimately differ in shape from one another, which is why it applies to `JSON` and `API` only:
|
|
1025
|
+
|
|
1026
|
+
- On `CSV`/`TXT`/`FIXED`/`PARQUET` it is **rejected**, not ignored — a positional column can't be missing from a row, and a parquet row group declares every column up front.
|
|
1027
|
+
- Only `null` is dropped. An empty string, a `0` and a `false` are values, and are exported.
|
|
1028
|
+
|
|
1029
|
+
### **CSV Output**
|
|
1030
|
+
|
|
1031
|
+
```json
|
|
1032
|
+
{
|
|
1033
|
+
"outputs": [
|
|
1034
|
+
{
|
|
1035
|
+
"format": "CSV",
|
|
1036
|
+
"exportDestination": "local-output",
|
|
1037
|
+
"exportName": "sales-report"
|
|
1038
|
+
}
|
|
1039
|
+
]
|
|
1040
|
+
}
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### **The Header Line**
|
|
1044
|
+
|
|
1045
|
+
A `CSV` or `TXT` file is positional: it carries values and nothing that names them. So both formats write a **header line** first — the exported field names, in column order, quoted and delimited exactly as the rows under them, so the header parses with the same reader the data does:
|
|
1046
|
+
|
|
1047
|
+
```
|
|
1048
|
+
"id","primary_email","plan","country"
|
|
1049
|
+
"USR-00001","alice.rossi@example.com","free","Italy"
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
The names are the exported ones: a field's `alias` where it has one, its `key` otherwise. A `hidden` field is not written, so it is not in the header either. With a `split`, **every** file of the output carries the header, not just the first one — each file has to stand on its own.
|
|
1053
|
+
|
|
1054
|
+
Set `header` to `false` where the destination reads the first line as data:
|
|
1055
|
+
|
|
1056
|
+
```json
|
|
1057
|
+
{
|
|
1058
|
+
"outputs": [
|
|
1059
|
+
{
|
|
1060
|
+
"format": "CSV",
|
|
1061
|
+
"exportDestination": "local-output",
|
|
1062
|
+
"exportName": "sales-report",
|
|
1063
|
+
"header": false
|
|
1064
|
+
}
|
|
1065
|
+
]
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
It only applies to `CSV` and `TXT`. On a `JSON` or `API` output each record already carries its own field names, a `PARQUET` file carries its own schema, and a `FIXED` line has no room for a name that would not sit on the columns it describes — so `header` is **rejected** at compile on all four rather than ignored.
|
|
1070
|
+
|
|
1071
|
+
### **How a value is written**
|
|
1072
|
+
|
|
1073
|
+
A `CSV`/`TXT` file writes every value quoted, and that leaves the unquoted empty field free to mean
|
|
1074
|
+
something: it is a **null**. An empty string is `""`. The two are different values, and on a
|
|
1075
|
+
positional line this is the only way to tell them apart:
|
|
1076
|
+
|
|
1077
|
+
```
|
|
1078
|
+
"id","note","amount"
|
|
1079
|
+
"USR-1","","12.5" <- note is the empty string
|
|
1080
|
+
"USR-2",, <- note and amount are null
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
A reader that treats an unquoted empty field as "no value" — which most CSV readers do, and which is
|
|
1084
|
+
what `COPY`, `LOAD DATA` and a pandas `read_csv` all assume — gets a null back. A reader that does
|
|
1085
|
+
not sees an empty string, exactly as before.
|
|
1086
|
+
|
|
1087
|
+
A value holding a **newline** or a carriage return is written as the two characters `\n` or `\r`.
|
|
1088
|
+
A line of a remora file is one record, everywhere in the pipeline, so a value carrying a real
|
|
1089
|
+
newline would otherwise arrive as two broken rows. The escape is one-way: nothing turns it back into
|
|
1090
|
+
a newline, and an ordinary backslash in your data (`C:\path`) is never touched. Use `JSON` or
|
|
1091
|
+
`PARQUET` where a value must carry a newline verbatim — both hold one natively.
|
|
1092
|
+
|
|
1093
|
+
### **TXT Output**
|
|
1094
|
+
|
|
1095
|
+
Delimited text export. The `delimiter` is configurable and defaults to a tab if omitted:
|
|
1096
|
+
|
|
1097
|
+
```json
|
|
1098
|
+
{
|
|
1099
|
+
"outputs": [
|
|
1100
|
+
{
|
|
1101
|
+
"format": "TXT",
|
|
1102
|
+
"exportDestination": "local-output",
|
|
1103
|
+
"exportName": "sales-report",
|
|
1104
|
+
"delimiter": "|"
|
|
1105
|
+
}
|
|
1106
|
+
]
|
|
1107
|
+
}
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
### **FIXED Output**
|
|
1111
|
+
|
|
1112
|
+
A fixed-width (**positional**) flat file: no delimiter, no quoting, no header line. Every field occupies the same columns on every record, and each value is filled out to the width its layout gives it. This is the write side of the `FIXED` producer (see the Producers guide) — what you export when the receiver reads by column rather than by field name: a mainframe feed, a clearing-house submission, a banking return file.
|
|
1113
|
+
|
|
1114
|
+
Each visible field declares a `position`, and the output declares how long a record is:
|
|
1115
|
+
|
|
1116
|
+
```json
|
|
1117
|
+
{
|
|
1118
|
+
"fields": [
|
|
1119
|
+
{ "key": "member_id", "position": { "width": 12, "start": 1 } },
|
|
1120
|
+
{ "key": "service_date", "position": { "width": 8, "start": 13 } },
|
|
1121
|
+
{ "key": "amount", "position": { "width": 10, "start": 21 } }
|
|
1122
|
+
],
|
|
1123
|
+
"outputs": [
|
|
1124
|
+
{
|
|
1125
|
+
"format": "FIXED",
|
|
1126
|
+
"exportDestination": "local-output",
|
|
1127
|
+
"exportName": "claims",
|
|
1128
|
+
"recordLength": 30
|
|
1129
|
+
}
|
|
1130
|
+
]
|
|
1131
|
+
}
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
```
|
|
1135
|
+
MBR000000123202604130000123.45
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
The file is exported with a `.txt` extension, to any file destination (local, S3, a pre-signed URL), with the same naming, [splitting](#output-splitting), trigger and `onSuccess` behaviour as every other file output.
|
|
1139
|
+
|
|
1140
|
+
### **How a Value Fills Its Field**
|
|
1141
|
+
|
|
1142
|
+
| Property | Required | Description | Example |
|
|
1143
|
+
| --- | --- | --- | --- |
|
|
1144
|
+
| `position.width` | ✅ | How many characters the field occupies on the line | `10` |
|
|
1145
|
+
| `position.align` | ❌ | Which end of the field the value sits at, the other end taking the padding. Defaults by type: `left` for a `string`, `datetime` or `boolean`, `right` for a `number` | `"left"`, `"right"` |
|
|
1146
|
+
| `position.pad` | ❌ | The single character the rest of the field is filled with. Defaults by type: a space for a `string`, `datetime` or `boolean`, `0` for a `number` | `" "`, `"0"` |
|
|
1147
|
+
| `position.nullFill` | ❌ | The single character a **null** field is filled with, for its full width. Defaults to a space for every type | `" "`, `"0"` |
|
|
1148
|
+
| `position.overflow` | ❌ | What a value longer than `width` does | `"fail"` (default), `"truncate"` |
|
|
1149
|
+
| `position.start` | ❌ | 1-based column the field is expected to start at. An assertion, not a second source of truth (see below) | `21` |
|
|
1150
|
+
|
|
1151
|
+
Five rules decide what lands on the line:
|
|
1152
|
+
|
|
1153
|
+
| Case | What is written |
|
|
1154
|
+
| --- | --- |
|
|
1155
|
+
| Value shorter than `width` | Padded out to `width` with `pad`, on the side `align` leaves free |
|
|
1156
|
+
| Value exactly `width` | The value |
|
|
1157
|
+
| Value longer than `width` | **The run fails**, naming the field, the value and both widths — unless `overflow` is `"truncate"` |
|
|
1158
|
+
| `null` | The full width in `nullFill` |
|
|
1159
|
+
| Empty string | The same as a `null`. The staged encoding keeps the two apart; a positional line cannot |
|
|
1160
|
+
|
|
1161
|
+
**Overflow fails rather than truncates.** A truncated account number is a plausible-looking account number, and a feed nobody re-reads is exactly where that ships unnoticed. `"overflow": "truncate"` exists per field for the layouts that genuinely specify a cut, and the end that survives follows `align`: a left-aligned value keeps its head, a right-aligned one keeps its low-order tail, which is what a COBOL `MOVE` into a shorter field does.
|
|
1162
|
+
|
|
1163
|
+
**A null is filled with spaces, not zeros, even on a number.** Zero-filling an empty numeric field writes `00`, which the receiver reads as the number zero — a real value where the record carried none. Set `nullFill` on the field, or per type in `defaults`, for a receiver that needs something else.
|
|
1164
|
+
|
|
1165
|
+
**Carry a positional field through as a `string`.** A `number` dimension drops leading zeros — `"00"` read as a number is `0` — so writing it back takes `"align": "right"` with `"pad": "0"`, a rule you have to get right field by field. Declaring the producer dimension a `string` instead carries the characters through untouched.
|
|
1166
|
+
|
|
1167
|
+
### **Column Order, Gaps and `start`**
|
|
1168
|
+
|
|
1169
|
+
**Column order is field order, and every offset is computed from the widths before it.** No column number is ever transcribed by hand: list the fields in the order the record is written, give each one a width, and the layout falls out of them. That is the same rule the read side follows, and it is why a layout is written once rather than renumbered every time a field moves.
|
|
1170
|
+
|
|
1171
|
+
Gaps and COBOL `FILLER` need no concept of their own — a [fixed field](#fixed-fields) with a width is blank columns:
|
|
1172
|
+
|
|
1173
|
+
```json
|
|
1174
|
+
{ "key": "filler", "fixed": true, "default": "", "position": { "width": 103 } }
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
**`position.start` and `recordLength` are checksums.** Where you write them, `compile` recomputes them from the widths and fails if they disagree. A layout spec gives you the widths, the start columns *and* the record length, and declaring all three is what catches a field you left out: a missing 8-character filler shifts every field after it, and nothing about the resulting file looks wrong.
|
|
1178
|
+
|
|
1179
|
+
`recordLength` is the length of the record **as written**, not the length of the copybook it came from. Where a 256-column layout is cut after the last field that carries anything, the fields past the cut are simply not declared — not padded to. Every record is then exactly `recordLength` characters, a record whose last field is null included: that field is written full width in `nullFill` and the line is never cut short.
|
|
1180
|
+
|
|
1181
|
+
`remora compile` prints the resolved layout, so you check it against the spec once instead of counting characters:
|
|
1182
|
+
|
|
1183
|
+
```
|
|
1184
|
+
FIXED layout of consumer "claims_export"
|
|
1185
|
+
record: 30 characters, 3 field(s)
|
|
1186
|
+
start end width field
|
|
1187
|
+
1 12 12 member_id
|
|
1188
|
+
13 20 8 service_date
|
|
1189
|
+
21 30 10 amount
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
Every problem with a layout is reported in one `compile` rather than one per run, so six fields missing a width come back as six lines. `run` and `verify` resolve the same layout before they read a producer, so a layout that does not add up fails in front of the run instead of after the whole dataset has been built.
|
|
1193
|
+
|
|
1194
|
+
### **Fill Rules by Type**
|
|
1195
|
+
|
|
1196
|
+
A wide layout states a rule once in `defaults` instead of on each of ninety fields, keyed by the field's dimension type:
|
|
1197
|
+
|
|
1198
|
+
```json
|
|
1199
|
+
{
|
|
1200
|
+
"outputs": [
|
|
1201
|
+
{
|
|
1202
|
+
"format": "FIXED",
|
|
1203
|
+
"exportDestination": "local-output",
|
|
1204
|
+
"recordLength": 256,
|
|
1205
|
+
"defaults": {
|
|
1206
|
+
"number": { "nullFill": "0" },
|
|
1207
|
+
"datetime": { "align": "right", "pad": "0" }
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
]
|
|
1211
|
+
}
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
Above: a receiver that reads a blank numeric field as an error gets `0` there instead, and dates written as bare digits are right-aligned and zero-padded like the numbers they look like.
|
|
1215
|
+
|
|
1216
|
+
Each of `align`, `pad`, `nullFill` and `overflow` resolves on its own, nearest declaration first: the field's own `position`, then the layout's `defaults`, then the output's `defaults`, then the type's built-in rule. A field that sets only `align` keeps the `pad` the defaults gave it.
|
|
1217
|
+
|
|
1218
|
+
### **Several Record Layouts in One File**
|
|
1219
|
+
|
|
1220
|
+
A positional feed often carries more than one kind of record in one file — a 44-character transaction and a 196-character one, told apart by a marker on the line. `layouts` is the write-side mirror of a producer's `recordSelector`: it names the field whose value picks the layout, and what each value writes.
|
|
1221
|
+
|
|
1222
|
+
```json
|
|
1223
|
+
{
|
|
1224
|
+
"outputs": [
|
|
1225
|
+
{
|
|
1226
|
+
"format": "FIXED",
|
|
1227
|
+
"exportDestination": "local-output",
|
|
1228
|
+
"exportName": "pod_return",
|
|
1229
|
+
"lineEnding": "crlf",
|
|
1230
|
+
"layouts": {
|
|
1231
|
+
"discriminator": "key_tran_id",
|
|
1232
|
+
"order": ["1300", "1200"],
|
|
1233
|
+
"values": {
|
|
1234
|
+
"1300": {
|
|
1235
|
+
"fields": ["key_serv_plan", "key_tran_id", "key_prov_id", "main_user"],
|
|
1236
|
+
"recordLength": 44
|
|
1237
|
+
},
|
|
1238
|
+
"1200": {
|
|
1239
|
+
"fields": ["key_serv_plan", "key_tran_id", "key_prov_id", "key_version", "main_user", "sort_date"],
|
|
1240
|
+
"recordLength": 196
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
]
|
|
1246
|
+
}
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
| Property | Required | Description |
|
|
1250
|
+
| --- | --- | --- |
|
|
1251
|
+
| `discriminator` | ✅ | The consumer field whose value picks the layout, by the name it is exported under. It does not have to be written by any of the layouts, though it usually is |
|
|
1252
|
+
| `order` | ✅ | The discriminator values in the order their record blocks are written. The complete list: a record carrying a value that is not here has no block to go in and **fails the run**, naming the value |
|
|
1253
|
+
| `values` | ✅ | One entry per value in `order`: the `fields` it writes in column order, plus its own `recordLength` and `defaults` |
|
|
1254
|
+
|
|
1255
|
+
How it behaves:
|
|
1256
|
+
|
|
1257
|
+
- **The records come out grouped**, one contiguous block per value of `order` — which is how a real positional feed is written. Within a block they keep the order they were read in; Remora has no output sort, so the order inside a block is the order the producers delivered. With a [`split`](#output-splitting), each file is grouped on its own.
|
|
1258
|
+
- The consumer's `fields` is the **union** of every layout's, and each layout names the subset its records carry, in its own column order. So one field can sit at different columns in different layouts — and that field then declares a `width` and no `start`, because a `start` is a column on one line and that field sits on several.
|
|
1259
|
+
- A field the consumer declares that no layout writes needs no `position`.
|
|
1260
|
+
- A `recordLength` on the output beside `layouts` is **rejected**: a multi-layout file has no single record length. Declare one per layout.
|
|
1261
|
+
- At most **64** layouts in one output.
|
|
1262
|
+
- Where the record types come from different producers, `union` still requires them to share their output dimensions: each producer declares the superset of fields and leaves the ones its layout has no field for empty.
|
|
1263
|
+
|
|
1264
|
+
### **How a Record Ends**
|
|
1265
|
+
|
|
1266
|
+
`lineEnding` is `"lf"` (the default) or `"crlf"`, and it ends **every** record, the last one included. A real positional feed picks one and holds it: NASCO POD files, for instance, are CRLF throughout.
|
|
1267
|
+
|
|
1268
|
+
A file with **no terminator at all** — fixed record framing, `RECFM=FB`, where the record length alone says where one record ends — is not expressible here, and asking for it is the signal that what is needed is byte-mode framing rather than a line-based file. Remora does not write one.
|
|
1269
|
+
|
|
1270
|
+
### **What the Receiver Has To Do**
|
|
1271
|
+
|
|
1272
|
+
The file Remora writes is **text, encoded UTF-8, one character per column**. For the layouts this was built for that is plain ASCII and the distinction never comes up — but a non-ASCII character (`é`, a typographic quote) is one column and more than one byte, so the record stays `recordLength` *characters* while the line stops being `recordLength` *bytes*. Where the receiver counts bytes, keep the data ASCII: a [`replace`](#pattern-replacement) transform on the field is the place to force it.
|
|
1273
|
+
|
|
1274
|
+
Two conversions a mainframe target needs are the receiver's, not Remora's, and are the mirror image of the ones the same shops already run on data coming the other way:
|
|
1275
|
+
|
|
1276
|
+
- **ASCII → EBCDIC.** A z/OS job reads EBCDIC; this file leaves as ASCII.
|
|
1277
|
+
- **Reframing to `RECFM=FB`.** This file leaves as line-terminated records; a fixed-block dataset carries no terminators at all.
|
|
1278
|
+
|
|
1279
|
+
Both are normally one step of the transfer job that delivers the file, not something the layout can express.
|
|
1280
|
+
|
|
1281
|
+
### **What a FIXED Output Cannot Declare**
|
|
1282
|
+
|
|
1283
|
+
Each of these is **rejected** at `compile` rather than ignored: silently dropping a rule you wrote is how a file ships looking almost right.
|
|
1284
|
+
|
|
1285
|
+
| Declaration | Why |
|
|
1286
|
+
| --- | --- |
|
|
1287
|
+
| `header` | A positional line has no room for one, and a field name would not sit on the columns it describes |
|
|
1288
|
+
| `delimiter` | A positional line has none: its fields are found by the columns they occupy |
|
|
1289
|
+
| `omitNull` | A column can't be missing from a positional row. A null is written as `nullFill`, full width |
|
|
1290
|
+
| `"key": "*"` | Every field of a positional line declares its own `width`, so the field list has to be explicit. A [pattern key](#pattern-keys) is allowed, but every column it matches takes the same `position` |
|
|
1291
|
+
|
|
1292
|
+
The reverse is rejected too: `recordLength`, `defaults`, `lineEnding` and `layouts` on a `CSV`, `TXT`, `JSON`, `PARQUET` or `API` output, and a field `position` on a consumer whose output is not `FIXED`.
|
|
1293
|
+
|
|
1294
|
+
### **PARQUET Output**
|
|
1295
|
+
|
|
1296
|
+
Apache Parquet: a typed, SNAPPY-compressed **columnar** file, which is what a data lake expects to read. It writes to any file destination (local, S3, a pre-signed URL) with the same naming, splitting and trigger behaviour as every other file output:
|
|
1297
|
+
|
|
1298
|
+
```json
|
|
1299
|
+
{
|
|
1300
|
+
"outputs": [
|
|
1301
|
+
{
|
|
1302
|
+
"format": "PARQUET",
|
|
1303
|
+
"exportDestination": "lake",
|
|
1304
|
+
"exportName": "sales"
|
|
1305
|
+
}
|
|
1306
|
+
]
|
|
1307
|
+
}
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
Two things it gives a reader that a `CSV` export cannot. The file **declares its types**, so nothing downstream has to guess them back out of the text. And it is much smaller: a four-column, 2-million-row dataset is about 28 MB as parquet against 115 MB as quoted CSV.
|
|
1311
|
+
|
|
1312
|
+
The schema comes from what the consumer already declares — each field's dimension type:
|
|
1313
|
+
|
|
1314
|
+
| Dimension type | Parquet column |
|
|
1315
|
+
| --- | --- |
|
|
1316
|
+
| `string` | `BYTE_ARRAY` / UTF8 |
|
|
1317
|
+
| `number` | `DOUBLE` — every value remora holds is a double, so an integer column would promise precision the record never had |
|
|
1318
|
+
| `boolean` | `BOOLEAN` |
|
|
1319
|
+
| `datetime` | `INT64` / `TIMESTAMP_MILLIS` |
|
|
1320
|
+
| *none* (a `fixed`, `lineCount` or `copyFrom` field, or a column matched by a pattern key) | `BYTE_ARRAY` / UTF8 — there is no declared type to read |
|
|
1321
|
+
|
|
1322
|
+
Every column is nullable, so **a null stays a null** rather than becoming `0`, `false` or `""` — in a column store those are different things. A `hidden` field is not in the file at all, not even as an empty column.
|
|
1323
|
+
|
|
1324
|
+
`header` is **rejected** on a parquet output: the file already carries its column names in its schema, and a header line would be a first row of data claiming to be one. `delimiter` means nothing to it and is ignored.
|
|
1325
|
+
|
|
1326
|
+
A parquet file is a container whose footer can only be written once the last row group is encoded, so it is built in full and then shipped, rather than streamed out a line at a time. One consequence is worth knowing about if you use `split`: **`split.maxFileSizeGb` caps the internal staged bytes, not the parquet bytes**, so the exported files come out comfortably *under* the cap rather than at it. See [Splitting the Output](#splitting-the-output).
|
|
1327
|
+
|
|
1328
|
+
### **API Output**
|
|
1329
|
+
|
|
1330
|
+
```json
|
|
1331
|
+
{
|
|
1332
|
+
"outputs": [
|
|
1333
|
+
{
|
|
1334
|
+
"format": "API"
|
|
1335
|
+
}
|
|
1336
|
+
]
|
|
1337
|
+
}
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
### **Multiple Outputs**
|
|
1341
|
+
|
|
1342
|
+
Export to multiple destinations simultaneously:
|
|
1343
|
+
|
|
1344
|
+
```json
|
|
1345
|
+
{
|
|
1346
|
+
"outputs": [
|
|
1347
|
+
{
|
|
1348
|
+
"format": "JSON",
|
|
1349
|
+
"exportDestination": "s3-archive"
|
|
1350
|
+
},
|
|
1351
|
+
{
|
|
1352
|
+
"format": "CSV",
|
|
1353
|
+
"exportDestination": "local-reports"
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
"format": "API"
|
|
1357
|
+
}
|
|
1358
|
+
]
|
|
1359
|
+
}
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
---
|
|
1363
|
+
|
|
1364
|
+
## **Automated Triggers**
|
|
1365
|
+
|
|
1366
|
+
Configure automated execution of consumers based on schedules, API calls, or queue messages.
|
|
1367
|
+
|
|
1368
|
+
### **Trigger Types**
|
|
1369
|
+
|
|
1370
|
+
### **CRON Triggers**
|
|
1371
|
+
|
|
1372
|
+
```json
|
|
1373
|
+
{
|
|
1374
|
+
"outputs": [
|
|
1375
|
+
{
|
|
1376
|
+
"format": "JSON",
|
|
1377
|
+
"exportDestination": "daily-exports",
|
|
1378
|
+
"trigger": {
|
|
1379
|
+
"type": "CRON",
|
|
1380
|
+
"value": "0 2 * * *",
|
|
1381
|
+
"metadata": {
|
|
1382
|
+
"timezone": "UTC",
|
|
1383
|
+
"description": "Daily at 2 AM"
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
]
|
|
1388
|
+
}
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
### **API Triggers**
|
|
1392
|
+
|
|
1393
|
+
```json
|
|
1394
|
+
{
|
|
1395
|
+
"outputs": [
|
|
1396
|
+
{
|
|
1397
|
+
"format": "JSON",
|
|
1398
|
+
"exportDestination": "api-exports",
|
|
1399
|
+
"trigger": {
|
|
1400
|
+
"type": "API",
|
|
1401
|
+
"value": "/api/export/customer-data",
|
|
1402
|
+
"metadata": {
|
|
1403
|
+
"method": "POST",
|
|
1404
|
+
"auth_required": "true"
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
]
|
|
1409
|
+
}
|
|
1410
|
+
```
|
|
1411
|
+
|
|
1412
|
+
### **Queue Triggers**
|
|
1413
|
+
|
|
1414
|
+
```json
|
|
1415
|
+
{
|
|
1416
|
+
"outputs": [
|
|
1417
|
+
{
|
|
1418
|
+
"format": "JSON",
|
|
1419
|
+
"exportDestination": "queue-exports",
|
|
1420
|
+
"trigger": {
|
|
1421
|
+
"type": "QUEUE",
|
|
1422
|
+
"value": "https://sqs.us-east-1.amazonaws.com/123456789012/data-processing-queue",
|
|
1423
|
+
"metadata": {
|
|
1424
|
+
"region": "us-east-1",
|
|
1425
|
+
"messageType": "data-update",
|
|
1426
|
+
"accessKeyId": "{AWS_ACCESS_KEY_ID}",
|
|
1427
|
+
"secretAccessKey": "{AWS_SECRET_ACCESS_KEY}"
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
]
|
|
1432
|
+
}
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
**QUEUE trigger metadata:**
|
|
1436
|
+
|
|
1437
|
+
| Field | Description |
|
|
1438
|
+
| --- | --- |
|
|
1439
|
+
| `region` | AWS region of the queue |
|
|
1440
|
+
| `accountId` | AWS account ID — used to build the full queue URL when `value` is only a queue name |
|
|
1441
|
+
| `messageType` | Only messages whose `type`/`messageType`/`eventType` matches are processed by this consumer; others are left on the queue. Enables one shared queue across multiple consumers |
|
|
1442
|
+
| `method` | Credential method: `access-secret-key` (default, static keys), `arn` (assume an IAM role via STS), or `implicit`/`iam` (AWS SDK default chain — instance/task role) |
|
|
1443
|
+
| `accessKeyId`, `secretAccessKey`, `sessionToken` | Static credentials (for `access-secret-key`) |
|
|
1444
|
+
| `roleArn`, `roleSessionName`, `externalId` | STS AssumeRole settings (for `method: "arn"`) |
|
|
1445
|
+
| `deadLetterQueue` | Queue URL or name where a repeatedly failing message is parked (see [Failed messages](#failed-messages)) |
|
|
1446
|
+
| `maxReceiveCount` | Failed deliveries tolerated before the message stops being processed. Defaults to `"5"` |
|
|
1447
|
+
|
|
1448
|
+
#### **Failed messages**
|
|
1449
|
+
|
|
1450
|
+
A message is only deleted from the queue once at least one consumer has processed it successfully —
|
|
1451
|
+
which is what keeps a shared queue usable. When every consumer that picked it up failed, the message
|
|
1452
|
+
stays in the queue and SQS redelivers it after the visibility timeout (5 minutes).
|
|
1453
|
+
|
|
1454
|
+
That is the right behaviour for a transient failure and the wrong one for a message that can never
|
|
1455
|
+
succeed — a malformed payload, a consumer whose source is gone. Such a message would trigger a full
|
|
1456
|
+
consumer run every five minutes until the queue's retention period (4 days by default) silently
|
|
1457
|
+
discards it: roughly a thousand failed runs.
|
|
1458
|
+
|
|
1459
|
+
So the run is bounded. Once SQS has delivered the message `maxReceiveCount` times and it has failed
|
|
1460
|
+
each time, it stops being processed. What happens next depends on `deadLetterQueue`:
|
|
1461
|
+
|
|
1462
|
+
| `deadLetterQueue` | What happens to the exhausted message |
|
|
1463
|
+
| --- | --- |
|
|
1464
|
+
| Set | Copied to that queue, then deleted from this one. The copy carries `remoraSourceQueue`, `remoraSourceMessageId` and `remoraReceiveCount` as message attributes, so it can be diagnosed and replayed |
|
|
1465
|
+
| Not set | Skipped on every later delivery, without running any consumer. Nothing is deleted — the message stays in the queue for inspection and expires with the queue's retention period |
|
|
1466
|
+
|
|
1467
|
+
```json
|
|
1468
|
+
{
|
|
1469
|
+
"trigger": {
|
|
1470
|
+
"type": "QUEUE",
|
|
1471
|
+
"value": "orders-queue",
|
|
1472
|
+
"metadata": {
|
|
1473
|
+
"region": "us-east-1",
|
|
1474
|
+
"accountId": "123456789012",
|
|
1475
|
+
"messageType": "data-update",
|
|
1476
|
+
"deadLetterQueue": "orders-queue-dlq",
|
|
1477
|
+
"maxReceiveCount": "3"
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
```
|
|
1482
|
+
|
|
1483
|
+
A dead-letter queue is worth configuring: without one the failure is bounded but invisible — the
|
|
1484
|
+
message is skipped, and the only trace is the worker's log and the `quarantinedCount` it reports.
|
|
1485
|
+
An SQS redrive policy on the queue itself does the same job, and applies to every system reading
|
|
1486
|
+
that queue rather than only to Remora.
|
|
1487
|
+
|
|
1488
|
+
Two things are deliberately *not* treated as failures. A message no consumer **claimed** — its
|
|
1489
|
+
`messageType` matched nobody — is left on the queue untouched for other systems, however many times
|
|
1490
|
+
it is redelivered. And the skip list is held in the worker process only, so a restart gives every
|
|
1491
|
+
message another chance, which is usually what should happen right after a fix is deployed.
|
|
1492
|
+
|
|
1493
|
+
None of this applies to the project's `API_QUEUE`, whose messages are commands rather than events: a
|
|
1494
|
+
failed command is never retried, it leaves the queue on its first delivery. See
|
|
1495
|
+
[As the execute-consumer API queue](Remora%20-%20Sources.md).
|
|
1496
|
+
|
|
1497
|
+
The delivery count itself comes from SQS, not from Remora, so it survives restarts and is shared
|
|
1498
|
+
across every worker polling the queue.
|
|
1499
|
+
|
|
1500
|
+
Example authenticating via an assumed IAM role instead of static keys:
|
|
1501
|
+
|
|
1502
|
+
```json
|
|
1503
|
+
{
|
|
1504
|
+
"trigger": {
|
|
1505
|
+
"type": "QUEUE",
|
|
1506
|
+
"value": "orders-queue",
|
|
1507
|
+
"metadata": {
|
|
1508
|
+
"region": "us-east-1",
|
|
1509
|
+
"accountId": "123456789012",
|
|
1510
|
+
"method": "arn",
|
|
1511
|
+
"roleArn": "arn:aws:iam::123456789012:role/remora-sqs",
|
|
1512
|
+
"messageType": "data-update"
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1518
|
+
### **Post-Processing Actions**
|
|
1519
|
+
|
|
1520
|
+
### **File Movement**
|
|
1521
|
+
|
|
1522
|
+
```json
|
|
1523
|
+
{
|
|
1524
|
+
"outputs": [
|
|
1525
|
+
{
|
|
1526
|
+
"format": "CSV",
|
|
1527
|
+
"exportDestination": "processing-folder",
|
|
1528
|
+
"onSuccess": [
|
|
1529
|
+
{
|
|
1530
|
+
"action": "move-file",
|
|
1531
|
+
"moveToDestination": "archive-folder"
|
|
1532
|
+
}
|
|
1533
|
+
],
|
|
1534
|
+
"onError": [
|
|
1535
|
+
{
|
|
1536
|
+
"action": "move-file",
|
|
1537
|
+
"moveToDestination": "error-folder"
|
|
1538
|
+
}
|
|
1539
|
+
]
|
|
1540
|
+
}
|
|
1541
|
+
]
|
|
1542
|
+
}
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
### **Run Consumer(s)**
|
|
1546
|
+
|
|
1547
|
+
The `run-consumer` action triggers one or more other consumers once the output finishes — on the success path (`onSuccess`) or the failure path (`onError`). Listed consumers run **sequentially** in declared order as fresh full runs. A downstream failure is **logged and swallowed**: it does not change the parent consumer's result or stop the rest of the chain.
|
|
1548
|
+
|
|
1549
|
+
```json
|
|
1550
|
+
{
|
|
1551
|
+
"outputs": [
|
|
1552
|
+
{
|
|
1553
|
+
"format": "JSON",
|
|
1554
|
+
"exportDestination": "processing-folder",
|
|
1555
|
+
"onSuccess": [
|
|
1556
|
+
{
|
|
1557
|
+
"action": "run-consumer",
|
|
1558
|
+
"consumers": ["c_downstream_report", "c_notify"]
|
|
1559
|
+
}
|
|
1560
|
+
],
|
|
1561
|
+
"onError": [
|
|
1562
|
+
{
|
|
1563
|
+
"action": "run-consumer",
|
|
1564
|
+
"consumers": ["c_alert"]
|
|
1565
|
+
}
|
|
1566
|
+
]
|
|
1567
|
+
}
|
|
1568
|
+
]
|
|
1569
|
+
}
|
|
1570
|
+
```
|
|
1571
|
+
|
|
1572
|
+
Each name in `consumers` must resolve to a consumer in the project (validated at compile time). Chains are guarded against cycles (e.g. `A → B → A` is detected and skipped) and capped at a maximum depth (`MAX_ON_FINISH_DEPTH`).
|
|
1573
|
+
|
|
1574
|
+
---
|
|
1575
|
+
|
|
1576
|
+
## **Consumer Options**
|
|
1577
|
+
|
|
1578
|
+
Additional processing options for consumers.
|
|
1579
|
+
|
|
1580
|
+
### **Options Properties**
|
|
1581
|
+
|
|
1582
|
+
| Property | Description | Example |
|
|
1583
|
+
| --- | --- | --- |
|
|
1584
|
+
| `distinct` | Return only unique records | `true`, `false` |
|
|
1585
|
+
| `distinctOn` | De-duplicate on specific key(s), keeping one record per key using a resolution rule | See below |
|
|
1586
|
+
| `pivot` | Turn row values into columns with an aggregation | See below |
|
|
1587
|
+
|
|
1588
|
+
```json
|
|
1589
|
+
{
|
|
1590
|
+
"options": {
|
|
1591
|
+
"distinct": true}
|
|
1592
|
+
}
|
|
1593
|
+
```
|
|
1594
|
+
|
|
1595
|
+
### **Distinct On**
|
|
1596
|
+
|
|
1597
|
+
`distinctOn` de-duplicates on one or more `keys` and, when duplicates collide, keeps a single record according to `resolution`. Strategies: `first`/`last` (by `orderBy`, with an optional `direction` of `asc`/`desc`), or `min`/`max` (of `orderBy`). Reference fields by their alias if set.
|
|
1598
|
+
|
|
1599
|
+
```json
|
|
1600
|
+
{
|
|
1601
|
+
"options": {
|
|
1602
|
+
"distinctOn": {
|
|
1603
|
+
"keys": ["customer_id"],
|
|
1604
|
+
"resolution": {
|
|
1605
|
+
"strategy": "max",
|
|
1606
|
+
"orderBy": "updated_at"
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
```
|
|
1612
|
+
|
|
1613
|
+
### **Pivot**
|
|
1614
|
+
|
|
1615
|
+
`pivot` groups the data by `rowKeys`, takes the distinct values of `pivotColumn` as new columns, and fills each cell by aggregating `valueColumn` with `aggregation` (`sum`, `count`, `avg`, `min`, `max`). Optionally restrict/prefix the generated columns with `pivotValues` and `columnPrefix`. Cannot be combined with `distinct`/`distinctOn`.
|
|
1616
|
+
|
|
1617
|
+
```json
|
|
1618
|
+
{
|
|
1619
|
+
"options": {
|
|
1620
|
+
"pivot": {
|
|
1621
|
+
"rowKeys": ["product"],
|
|
1622
|
+
"pivotColumn": "region",
|
|
1623
|
+
"valueColumn": "revenue",
|
|
1624
|
+
"aggregation": "sum",
|
|
1625
|
+
"pivotValues": ["East", "West"],
|
|
1626
|
+
"columnPrefix": "revenue_"
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
---
|
|
1633
|
+
|
|
1634
|
+
## **Dataset Validation**
|
|
1635
|
+
|
|
1636
|
+
Beyond per-field [validation](#field-validation), a consumer can assert conditions on the **whole result set** before it's exported, via the top-level `validate` array. Each entry is a `{ rule, onFail }` pair where `onFail` is `fail` (abort) or `warn` (log and continue).
|
|
1637
|
+
|
|
1638
|
+
**Rules:** `unique_fields` (the given field(s) must be unique across the dataset), `min_rows` / `max_rows` (row-count bounds), `no_duplicates` (no fully-duplicate rows), `not_empty` (at least one row).
|
|
1639
|
+
|
|
1640
|
+
```json
|
|
1641
|
+
{
|
|
1642
|
+
"validate": [
|
|
1643
|
+
{ "rule": { "not_empty": true }, "onFail": "fail" },
|
|
1644
|
+
{ "rule": { "unique_fields": ["customer_id"] }, "onFail": "fail" },
|
|
1645
|
+
{ "rule": { "min_rows": 1 }, "onFail": "warn" }
|
|
1646
|
+
]
|
|
1647
|
+
}
|
|
1648
|
+
```
|
|
1649
|
+
|
|
1650
|
+
---
|
|
1651
|
+
|
|
1652
|
+
## **Output Splitting**
|
|
1653
|
+
|
|
1654
|
+
A file output can be broken into several files, by the value of a field, by a size cap, or by both. One `split` block covers all three:
|
|
1655
|
+
|
|
1656
|
+
```json
|
|
1657
|
+
{
|
|
1658
|
+
"split": {
|
|
1659
|
+
"by": ["country"],
|
|
1660
|
+
"maxFileSizeGb": 2
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
| Property | Meaning |
|
|
1666
|
+
| --- | --- |
|
|
1667
|
+
| `by` | Consumer field key(s) — the **alias** where one is set. Every distinct combination of values is written to its own file, named after the value(s). |
|
|
1668
|
+
| `maxFileSizeGb` | The largest **one** output file may get, in Gb. The file is rotated and a numbered one is started. With `by` set the cap applies per key, not across the whole output. |
|
|
1669
|
+
|
|
1670
|
+
Either property works on its own. The order is fixed: the key decides which rows share a file, the cap then decides how large one of those files may get — rotating across mixed keys would only produce files that need splitting again.
|
|
1671
|
+
|
|
1672
|
+
### **How the files are named**
|
|
1673
|
+
|
|
1674
|
+
Each file carries a label made of the key value, the rotation index, or both, placed before the extension:
|
|
1675
|
+
|
|
1676
|
+
The extension is the output's own (`.csv`, `.txt`, `.jsonl`, `.parquet`); `.csv` is used here as the example:
|
|
1677
|
+
|
|
1678
|
+
| Configuration | Exported files |
|
|
1679
|
+
| --- | --- |
|
|
1680
|
+
| no split | `sales_0.csv` |
|
|
1681
|
+
| `maxFileSizeGb` only | `sales_0.csv`, `sales_1.csv`, `sales_2.csv` |
|
|
1682
|
+
| `by` only | `sales_Italy.csv`, `sales_Spain.csv` |
|
|
1683
|
+
| both | `sales_Italy_0.csv`, `sales_Italy_1.csv`, `sales_Spain_0.csv` |
|
|
1684
|
+
|
|
1685
|
+
A value that cannot go in a file name (a separator, a space, a dot) becomes an underscore, and a long one is cut short — so two values that would produce the same name are kept apart by a numeric suffix rather than written into one file.
|
|
1686
|
+
|
|
1687
|
+
A row whose key is **null** goes to `_null` and one whose key is **empty** goes to `_empty`. They are separate on purpose: a shared catch-all merges two different things and stays invisible until it matters.
|
|
1688
|
+
|
|
1689
|
+
### **Limits**
|
|
1690
|
+
|
|
1691
|
+
- **1000 files** per consumer. Splitting on a field with one value per row (a customer id, an order number) fails the run with a named error instead of filling a directory.
|
|
1692
|
+
- An **API** output cannot be split — a response is not a file.
|
|
1693
|
+
- A **hidden** field cannot be a split key when the consumer writes a positional format (`CSV`, `TXT`, `PARQUET`): the file never carries that column.
|
|
1694
|
+
- On a `PARQUET` output, `maxFileSizeGb` caps the **internal staged bytes, not the parquet bytes**. Each file is encoded and compressed after it has been split, so the exported files land comfortably under the cap rather than at it. The cap is there to bound what a destination is asked to take in one object, and rotating a parquet file after it was compressed would mean rewriting it.
|
|
1695
|
+
|
|
1696
|
+
### **`maximumFileSize` (shorthand)**
|
|
1697
|
+
|
|
1698
|
+
`maximumFileSize` is the older spelling of `split.maxFileSizeGb` and keeps working on its own:
|
|
1699
|
+
|
|
1700
|
+
```json
|
|
1701
|
+
{
|
|
1702
|
+
"maximumFileSize": 2
|
|
1703
|
+
}
|
|
1704
|
+
```
|
|
1705
|
+
|
|
1706
|
+
Setting both on one consumer is rejected at compile — move the value into `split` and remove `maximumFileSize`.
|
|
1707
|
+
|
|
1708
|
+
---
|
|
1709
|
+
|
|
1710
|
+
## **Metadata and Organization**
|
|
1711
|
+
|
|
1712
|
+
### **Custom Metadata**
|
|
1713
|
+
|
|
1714
|
+
Add custom tags for organization and filtering:
|
|
1715
|
+
|
|
1716
|
+
```json
|
|
1717
|
+
{
|
|
1718
|
+
"metadata": {
|
|
1719
|
+
"department": "sales",
|
|
1720
|
+
"priority": "high",
|
|
1721
|
+
"data_classification": "internal",
|
|
1722
|
+
"owner": "analytics-team"
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
```
|
|
1726
|
+
|
|
1727
|
+
### **Project Organization**
|
|
1728
|
+
|
|
1729
|
+
Group consumers into projects for access control:
|
|
1730
|
+
|
|
1731
|
+
```json
|
|
1732
|
+
{
|
|
1733
|
+
"project": "customer-analytics"
|
|
1734
|
+
}
|
|
1735
|
+
```
|
|
1736
|
+
|
|
1737
|
+
### **Schema Validation**
|
|
1738
|
+
|
|
1739
|
+
Validate output against JSON schemas:
|
|
1740
|
+
|
|
1741
|
+
```json
|
|
1742
|
+
{
|
|
1743
|
+
"schema": "customer-data-schema"
|
|
1744
|
+
}
|
|
1745
|
+
```
|
|
1746
|
+
|
|
1747
|
+
---
|
|
1748
|
+
|
|
1749
|
+
## **Advanced Examples**
|
|
1750
|
+
|
|
1751
|
+
### **Healthcare Claims Analysis Consumer**
|
|
1752
|
+
|
|
1753
|
+
```json
|
|
1754
|
+
{
|
|
1755
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
|
|
1756
|
+
"name": "healthcare-claims-analysis",
|
|
1757
|
+
"description": "Comprehensive healthcare claims analysis with patient demographics",
|
|
1758
|
+
"producers": [
|
|
1759
|
+
{ "name": "healthcare-claims" },
|
|
1760
|
+
{
|
|
1761
|
+
"name": "patient-demographics",
|
|
1762
|
+
"joins": [
|
|
1763
|
+
{
|
|
1764
|
+
"otherName": "healthcare-claims",
|
|
1765
|
+
"relationship": "many-to-one",
|
|
1766
|
+
"sql": "${P.member_id} = ${healthcare-claims.member_id}"
|
|
1767
|
+
}
|
|
1768
|
+
]
|
|
1769
|
+
}
|
|
1770
|
+
],
|
|
1771
|
+
"fields": [
|
|
1772
|
+
{ "key": "claim_id", "alias": "Claim ID" },
|
|
1773
|
+
{ "key": "service_date", "alias": "Service Date" },
|
|
1774
|
+
{ "key": "diagnosis_code", "alias": "Primary Diagnosis" },
|
|
1775
|
+
{ "key": "billed_amount", "alias": "Billed Amount" },
|
|
1776
|
+
{ "key": "allowed_amount", "alias": "Allowed Amount" },
|
|
1777
|
+
{
|
|
1778
|
+
"key": "patient_age_group",
|
|
1779
|
+
"from": "patient-demographics",
|
|
1780
|
+
"alias": "Age Group"
|
|
1781
|
+
},
|
|
1782
|
+
{
|
|
1783
|
+
"key": "billed_vs_allowed",
|
|
1784
|
+
"transform": {
|
|
1785
|
+
"combine_fields": {
|
|
1786
|
+
"fields": ["billed_amount", "allowed_amount"],
|
|
1787
|
+
"template": "{billed_amount} billed / {allowed_amount} allowed"
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
"key": "high_value_claim",
|
|
1793
|
+
"transform": {
|
|
1794
|
+
"conditional": {
|
|
1795
|
+
"clauses": [
|
|
1796
|
+
{ "if": { "greater_than": 10000 }, "then": true }
|
|
1797
|
+
],
|
|
1798
|
+
"else": false}
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
],
|
|
1802
|
+
"filters": [
|
|
1803
|
+
{
|
|
1804
|
+
"rule": {
|
|
1805
|
+
"member": "service_date",
|
|
1806
|
+
"operator": "between",
|
|
1807
|
+
"values": ["2024-01-01", "2024-12-31"]
|
|
1808
|
+
}
|
|
1809
|
+
},
|
|
1810
|
+
{
|
|
1811
|
+
"rule": {
|
|
1812
|
+
"member": "claim_status",
|
|
1813
|
+
"operator": "equals",
|
|
1814
|
+
"values": ["Processed"]
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
],
|
|
1818
|
+
"outputs": [
|
|
1819
|
+
{
|
|
1820
|
+
"format": "JSON",
|
|
1821
|
+
"exportDestination": "analytics-bucket",
|
|
1822
|
+
"exportName": "claims-analysis-2024"
|
|
1823
|
+
},
|
|
1824
|
+
{
|
|
1825
|
+
"format": "CSV",
|
|
1826
|
+
"exportDestination": "reports-folder",
|
|
1827
|
+
"exportName": "claims-summary"
|
|
1828
|
+
}
|
|
1829
|
+
],
|
|
1830
|
+
"options": {
|
|
1831
|
+
"distinct": true},
|
|
1832
|
+
"metadata": {
|
|
1833
|
+
"department": "healthcare-analytics",
|
|
1834
|
+
"data_classification": "PHI",
|
|
1835
|
+
"retention_period": "7_years"
|
|
1836
|
+
},
|
|
1837
|
+
"project": "healthcare-analytics",
|
|
1838
|
+
"_version": 1
|
|
1839
|
+
}
|
|
1840
|
+
```
|
|
1841
|
+
|
|
1842
|
+
### **Sales Performance Dashboard Consumer**
|
|
1843
|
+
|
|
1844
|
+
```json
|
|
1845
|
+
{
|
|
1846
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
|
|
1847
|
+
"name": "sales-performance-dashboard",
|
|
1848
|
+
"description": "Real-time sales performance metrics for executive dashboard",
|
|
1849
|
+
"producers": [
|
|
1850
|
+
{ "name": "sales-transactions" },
|
|
1851
|
+
{
|
|
1852
|
+
"name": "product-catalog",
|
|
1853
|
+
"joins": [
|
|
1854
|
+
{
|
|
1855
|
+
"otherName": "sales-transactions",
|
|
1856
|
+
"relationship": "many-to-one",
|
|
1857
|
+
"sql": "${P.product_id} = ${sales-transactions.product_id}"
|
|
1858
|
+
}
|
|
1859
|
+
]
|
|
1860
|
+
},
|
|
1861
|
+
{
|
|
1862
|
+
"name": "sales-reps",
|
|
1863
|
+
"joins": [
|
|
1864
|
+
{
|
|
1865
|
+
"otherName": "sales-transactions",
|
|
1866
|
+
"relationship": "many-to-one",
|
|
1867
|
+
"sql": "${P.rep_id} = ${sales-transactions.sales_rep_id}"
|
|
1868
|
+
}
|
|
1869
|
+
]
|
|
1870
|
+
}
|
|
1871
|
+
],
|
|
1872
|
+
"fields": [
|
|
1873
|
+
{ "key": "transaction_id", "alias": "Transaction ID" },
|
|
1874
|
+
{ "key": "sale_date", "alias": "Sale Date" },
|
|
1875
|
+
{
|
|
1876
|
+
"key": "sale_month",
|
|
1877
|
+
"transform": { "extract": "month" },
|
|
1878
|
+
"alias": "Month"
|
|
1879
|
+
},
|
|
1880
|
+
{
|
|
1881
|
+
"key": "sale_year",
|
|
1882
|
+
"transform": { "extract": "year" },
|
|
1883
|
+
"alias": "Year"
|
|
1884
|
+
},
|
|
1885
|
+
{ "key": "amount", "alias": "Sale Amount" },
|
|
1886
|
+
{
|
|
1887
|
+
"key": "commission",
|
|
1888
|
+
"transform": { "multiply": 0.05 },
|
|
1889
|
+
"alias": "Commission (5%)"
|
|
1890
|
+
},
|
|
1891
|
+
{ "key": "product_name", "from": "product-catalog", "alias": "Product" },
|
|
1892
|
+
{ "key": "category", "from": "product-catalog", "alias": "Category" },
|
|
1893
|
+
{ "key": "rep_name", "from": "sales-reps", "alias": "Sales Rep" },
|
|
1894
|
+
{ "key": "region", "from": "sales-reps", "alias": "Region" },
|
|
1895
|
+
{
|
|
1896
|
+
"key": "performance_tier",
|
|
1897
|
+
"transform": {
|
|
1898
|
+
"conditional": {
|
|
1899
|
+
"clauses": [
|
|
1900
|
+
{ "if": { "greater_than_or_equal": 50000 }, "then": "Platinum" },
|
|
1901
|
+
{ "if": { "greater_than_or_equal": 25000 }, "then": "Gold" },
|
|
1902
|
+
{ "if": { "greater_than_or_equal": 10000 }, "then": "Silver" }
|
|
1903
|
+
],
|
|
1904
|
+
"else": "Bronze"
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
],
|
|
1909
|
+
"filters": [
|
|
1910
|
+
{
|
|
1911
|
+
"rule": {
|
|
1912
|
+
"member": "sale_date",
|
|
1913
|
+
"operator": "greaterThanOrEquals",
|
|
1914
|
+
"values": ["2024-01-01"]
|
|
1915
|
+
}
|
|
1916
|
+
},
|
|
1917
|
+
{
|
|
1918
|
+
"rule": {
|
|
1919
|
+
"member": "status",
|
|
1920
|
+
"operator": "equals",
|
|
1921
|
+
"values": ["Completed"]
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
],
|
|
1925
|
+
"outputs": [
|
|
1926
|
+
{
|
|
1927
|
+
"format": "API"
|
|
1928
|
+
},
|
|
1929
|
+
{
|
|
1930
|
+
"format": "JSON",
|
|
1931
|
+
"exportDestination": "dashboard-cache",
|
|
1932
|
+
"trigger": {
|
|
1933
|
+
"type": "CRON",
|
|
1934
|
+
"value": "*/15 * * * *",
|
|
1935
|
+
"metadata": {
|
|
1936
|
+
"description": "Update every 15 minutes"
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
],
|
|
1941
|
+
"metadata": {
|
|
1942
|
+
"dashboard": "executive-sales",
|
|
1943
|
+
"refresh_rate": "15_minutes",
|
|
1944
|
+
"data_classification": "internal"
|
|
1945
|
+
},
|
|
1946
|
+
"project": "sales-analytics",
|
|
1947
|
+
"_version": 1
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
```
|
|
1951
|
+
|
|
1952
|
+
### **Data Union Consumer**
|
|
1953
|
+
|
|
1954
|
+
```json
|
|
1955
|
+
{
|
|
1956
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
|
|
1957
|
+
"name": "historical-sales-union",
|
|
1958
|
+
"description": "Combined historical sales data from multiple years",
|
|
1959
|
+
"producers": [
|
|
1960
|
+
{ "name": "sales-2022", "union": true },
|
|
1961
|
+
{ "name": "sales-2023", "union": true },
|
|
1962
|
+
{ "name": "sales-2024", "union": true }
|
|
1963
|
+
],
|
|
1964
|
+
"fields": [
|
|
1965
|
+
{ "key": "transaction_id", "alias": "ID" },
|
|
1966
|
+
{ "key": "customer_id", "alias": "Customer" },
|
|
1967
|
+
{ "key": "product_id", "alias": "Product" },
|
|
1968
|
+
{ "key": "sale_date", "alias": "Date" },
|
|
1969
|
+
{ "key": "amount", "alias": "Amount" },
|
|
1970
|
+
{
|
|
1971
|
+
"key": "data_source",
|
|
1972
|
+
"transform": { "extract": "year" },
|
|
1973
|
+
"alias": "Source Year"
|
|
1974
|
+
}
|
|
1975
|
+
],
|
|
1976
|
+
"filters": [
|
|
1977
|
+
{
|
|
1978
|
+
"rule": {
|
|
1979
|
+
"member": "amount",
|
|
1980
|
+
"operator": "greaterThan",
|
|
1981
|
+
"values": ["0"]
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
],
|
|
1985
|
+
"outputs": [
|
|
1986
|
+
{
|
|
1987
|
+
"format": "CSV",
|
|
1988
|
+
"exportDestination": "data-warehouse",
|
|
1989
|
+
"exportName": "historical-sales-combined"
|
|
1990
|
+
}
|
|
1991
|
+
],
|
|
1992
|
+
"options": {
|
|
1993
|
+
"distinct": true},
|
|
1994
|
+
"_version": 1
|
|
1995
|
+
}
|
|
1996
|
+
```
|
|
1997
|
+
|
|
1998
|
+
---
|
|
1999
|
+
|
|
2000
|
+
## **Validation and Testing**
|
|
2001
|
+
|
|
2002
|
+
### **Configuration Validation**
|
|
2003
|
+
|
|
2004
|
+
```bash
|
|
2005
|
+
# Validate (compile) all resource configurations in the project
|
|
2006
|
+
remora compile
|
|
2007
|
+
```
|
|
2008
|
+
|
|
2009
|
+
### **Consumer Testing**
|
|
2010
|
+
|
|
2011
|
+
```bash
|
|
2012
|
+
# Run a single consumer
|
|
2013
|
+
remora run consumer-name
|
|
2014
|
+
|
|
2015
|
+
# Run with a limited number of output records (useful for a quick test)
|
|
2016
|
+
remora run consumer-name --limit 10
|
|
2017
|
+
|
|
2018
|
+
# Run every consumer belonging to a project
|
|
2019
|
+
remora run --project project-name
|
|
2020
|
+
```
|
|
2021
|
+
|
|
2022
|
+
#### Sampling a consumer
|
|
2023
|
+
|
|
2024
|
+
```bash
|
|
2025
|
+
# See what this consumer produces, without writing anything anywhere
|
|
2026
|
+
remora sample consumer-name
|
|
2027
|
+
|
|
2028
|
+
# Show a specific number of rows
|
|
2029
|
+
remora sample consumer-name -s 20
|
|
2030
|
+
```
|
|
2031
|
+
|
|
2032
|
+
`remora sample` on a consumer **runs its pipeline for real** — filters, field transformations,
|
|
2033
|
+
masking, unions, `distinct`, `pivot` and dataset validation all apply, exactly as in a
|
|
2034
|
+
run. So the rows it shows are the rows the consumer produces, always: it is a real run's output, not
|
|
2035
|
+
an approximation of it.
|
|
2036
|
+
|
|
2037
|
+
What it does *not* do is touch anything. A sample writes **nothing to the destination**, fires **no**
|
|
2038
|
+
`onSuccess`/`onError` action, and — although it is recorded like any execution — is marked as a
|
|
2039
|
+
sample, so it stays out of your run history, out of a consumer's run list and out of its duration
|
|
2040
|
+
averages.
|
|
2041
|
+
|
|
2042
|
+
The number of rows is bounded by the same limit `remora run --limit` uses, which means the cost
|
|
2043
|
+
depends on what the consumer does. Where nothing between the source and the output can *drop* a
|
|
2044
|
+
record, the limit reaches the source itself and the read stops early, so the sample is quick whatever
|
|
2045
|
+
the source's size. Where something can — any filter, `distinct`, `distinctOn`, `pivot`, or a field
|
|
2046
|
+
validation set to `"onFail": "skip"` — there is no way to know how many rows have to go in for ten to
|
|
2047
|
+
come out, so **the whole source is read**. On a large source that is slow. It is still only a read:
|
|
2048
|
+
nothing is written and nothing is recorded against the consumer.
|
|
2049
|
+
|
|
2050
|
+
Sampling a consumer needs a source the pipeline can read — `local`, `aws-s3` or `delta-share`. A
|
|
2051
|
+
consumer whose producer reads `aws-redshift` or `http-api` is refused with the reason, naming the
|
|
2052
|
+
producer; sample that **producer** directly to see its rows.
|
|
2053
|
+
|
|
2054
|
+
### **Common Validation Errors**
|
|
2055
|
+
|
|
2056
|
+
| Error | Cause | Solution |
|
|
2057
|
+
| --- | --- | --- |
|
|
2058
|
+
| `Producer not found` | Invalid producer reference | Check producer name exists |
|
|
2059
|
+
| `Join relationship invalid` | Incorrect join SQL or relationship type | Verify join syntax and relationship |
|
|
2060
|
+
| `Field not found` | Field doesn't exist in producer | Check field names and aliases |
|
|
2061
|
+
| `Ambiguous field reference` | Field exists in multiple producers | Use `from` property to specify producer |
|
|
2062
|
+
| `Invalid transformation` | Unsupported transformation or wrong parameters | Check transformation syntax |
|
|
2063
|
+
| `Filter validation failed` | Filter references non-existent field | Ensure filter fields exist in consumer |
|
|
2064
|
+
|
|
2065
|
+
---
|
|
2066
|
+
|
|
2067
|
+
## **Performance Optimization**
|
|
2068
|
+
|
|
2069
|
+
### **SQL-Based Optimization**
|
|
2070
|
+
|
|
2071
|
+
1. **Use appropriate join relationships** to optimize query execution
|
|
2072
|
+
2. **Filter early** with WHERE clauses to reduce data processing
|
|
2073
|
+
3. **Limit field selection** to reduce memory usage
|
|
2074
|
+
|
|
2075
|
+
### **File-Based Optimization**
|
|
2076
|
+
|
|
2077
|
+
1. **Use rule-based filters** instead of processing all data
|
|
2078
|
+
2. **Select only needed fields** to reduce memory footprint
|
|
2079
|
+
3. **Use streaming** for large file processing
|
|
2080
|
+
4. **Optimize transformations** to avoid expensive operations
|
|
2081
|
+
|
|
2082
|
+
### **Memory Management**
|
|
2083
|
+
|
|
2084
|
+
Configure memory settings in project configuration:
|
|
2085
|
+
|
|
2086
|
+
```json
|
|
2087
|
+
{
|
|
2088
|
+
"settings": {
|
|
2089
|
+
"MAX_ITEMS_IN_MEMORY": 100000,
|
|
2090
|
+
"INDICATIVE_THREAD_LINE_COUNT": 10000,
|
|
2091
|
+
"MAX_THREAD_COUNT": 4
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
```
|
|
2095
|
+
|
|
2096
|
+
---
|
|
2097
|
+
|
|
2098
|
+
## **Best Practices**
|
|
2099
|
+
|
|
2100
|
+
### **Naming Conventions**
|
|
2101
|
+
|
|
2102
|
+
- **Consumer names**: Use descriptive, lowercase with hyphens (`healthcare-claims-analysis`)
|
|
2103
|
+
- **Field aliases**: Use descriptive, human-readable names (`"Customer ID"`, `"Total Sales"`)
|
|
2104
|
+
- **Export names**: Use consistent naming for file exports (`daily-report`, `customer-analysis-2024`)
|
|
2105
|
+
|
|
2106
|
+
### **Performance**
|
|
2107
|
+
|
|
2108
|
+
1. **Filter early and often** to reduce data processing
|
|
2109
|
+
2. **Use appropriate join types** for your data relationships
|
|
2110
|
+
3. **Limit field selection** to only what's needed
|
|
2111
|
+
4. **Monitor execution times** and optimize bottlenecks
|
|
2112
|
+
|
|
2113
|
+
### **Security**
|
|
2114
|
+
|
|
2115
|
+
1. **Apply filters** to limit data exposure
|
|
2116
|
+
2. **Use project organization** for access control
|
|
2117
|
+
3. **Document data classifications** in metadata
|
|
2118
|
+
4. **Validate schema compliance** where required
|
|
2119
|
+
|
|
2120
|
+
### **Maintainability**
|
|
2121
|
+
|
|
2122
|
+
1. **Add meaningful descriptions** to all consumers
|
|
2123
|
+
2. **Use consistent field aliasing** across related consumers
|
|
2124
|
+
3. **Document business logic** in transformation comments
|
|
2125
|
+
4. **Version control** consumer configurations
|
|
2126
|
+
5. **Test thoroughly** before production deployment
|
|
2127
|
+
|
|
2128
|
+
---
|
|
2129
|
+
|
|
2130
|
+
## **Troubleshooting**
|
|
2131
|
+
|
|
2132
|
+
### **Common Issues**
|
|
2133
|
+
|
|
2134
|
+
**Join Failures:**
|
|
2135
|
+
|
|
2136
|
+
```
|
|
2137
|
+
Error: Unable to join producers - relationship constraint violated
|
|
2138
|
+
```
|
|
2139
|
+
|
|
2140
|
+
- Verify join relationships match data cardinality
|
|
2141
|
+
- Check join SQL syntax and field references
|
|
2142
|
+
- Ensure joined fields have compatible data types
|
|
2143
|
+
|
|
2144
|
+
**Transformation Errors:**
|
|
2145
|
+
|
|
2146
|
+
```
|
|
2147
|
+
Error: Cast transformation failed for field 'amount'
|
|
2148
|
+
```
|
|
2149
|
+
|
|
2150
|
+
- Check source data format and target type compatibility
|
|
2151
|
+
- Add error handling with `onError: "set_default"`
|
|
2152
|
+
- Verify transformation parameters
|
|
2153
|
+
|
|
2154
|
+
**Filter Errors:**
|
|
2155
|
+
|
|
2156
|
+
```
|
|
2157
|
+
Error: Filter field 'status' not found in consumer
|
|
2158
|
+
```
|
|
2159
|
+
|
|
2160
|
+
- Ensure filtered fields exist in field selection
|
|
2161
|
+
- Check field aliases in filter references
|
|
2162
|
+
- Verify producer field names
|
|
2163
|
+
|
|
2164
|
+
**Memory Issues:**
|
|
2165
|
+
|
|
2166
|
+
```
|
|
2167
|
+
Error: Out of memory processing large dataset
|
|
2168
|
+
```
|
|
2169
|
+
|
|
2170
|
+
- Increase `MAX_ITEMS_IN_MEMORY` setting
|
|
2171
|
+
- Add filters to reduce data size
|
|
2172
|
+
- Use streaming for large files
|
|
2173
|
+
- Optimize field selection
|
|
2174
|
+
|
|
2175
|
+
### **Debug Mode**
|
|
2176
|
+
|
|
2177
|
+
Enable detailed logging:
|
|
2178
|
+
|
|
2179
|
+
```json
|
|
2180
|
+
{
|
|
2181
|
+
"settings": {
|
|
2182
|
+
"LOG_LEVEL": "DEBUG",
|
|
2183
|
+
"DEBUG_MODE": true
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
```
|
|
2187
|
+
|
|
2188
|
+
### **Support Resources**
|
|
2189
|
+
|
|
2190
|
+
- **Schema validation**: Automatic validation against JSON schemas
|
|
2191
|
+
- **CLI help**: Run `remora --help` for command documentation
|
|
2192
|
+
- **Error messages**: Include specific guidance for resolution
|
|
2193
|
+
- **Execution stats**: Review processing statistics for optimization
|
|
2194
|
+
|
|
2195
|
+
---
|
|
2196
|
+
|
|
2197
|
+
## **Migration and Versioning**
|
|
2198
|
+
|
|
2199
|
+
### **Consumer Versioning**
|
|
2200
|
+
|
|
2201
|
+
Update the `_version` field when making significant changes:
|
|
2202
|
+
|
|
2203
|
+
```json
|
|
2204
|
+
{
|
|
2205
|
+
"name": "customer-analysis",
|
|
2206
|
+
"_version": 2
|
|
2207
|
+
}
|
|
2208
|
+
```
|
|
2209
|
+
|
|
2210
|
+
### **Schema Evolution**
|
|
2211
|
+
|
|
2212
|
+
When evolving consumer configurations:
|
|
2213
|
+
|
|
2214
|
+
1. **Test changes** in development environment first
|
|
2215
|
+
2. **Update field references** when producer schemas change
|
|
2216
|
+
3. **Maintain backwards compatibility** where possible
|
|
2217
|
+
4. **Document breaking changes** in version notes
|
|
2218
|
+
|
|
2219
|
+
### **Deployment Strategies**
|
|
2220
|
+
|
|
2221
|
+
1. **Blue-green deployment** for production consumers
|
|
2222
|
+
2. **Canary releases** for high-volume consumers
|
|
2223
|
+
3. **Rollback procedures** for failed deployments
|
|
2224
|
+
4. **Health checks** for automated consumers
|