Skip to content

Commit ed45d40

Browse files
committed
etl: add inspect APIs as destination-less dry-run
* add Go and Python inspect APIs that runs ETL dry-run without requiring callers to provide a destination bucket * keep inspect on the existing dry-run execution path, with no transformed objects written back to storage * add integration test coverage for validation failures and exact ETL error reporting Signed-off-by: Tony Chen <a122774007@gmail.com>
1 parent 8949c6a commit ed45d40

12 files changed

Lines changed: 310 additions & 1 deletion

File tree

ais/proxy.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1644,7 +1644,9 @@ func (p *proxy) _bckpost(w http.ResponseWriter, r *http.Request, msg *apc.ActMsg
16441644
return
16451645
}
16461646
tcbmsg.Prefix = cos.TrimPrefix(tcbmsg.Prefix)
1647-
if bckFrom.Equal(bckTo, true /*same BID*/, true) {
1647+
// Same-bucket dry-run is safe for copy and ETL: it exercises the TCB path
1648+
// without writing objects. ETL inspection uses this request shape.
1649+
if !tcbmsg.DryRun && bckFrom.Equal(bckTo, true /*same BID*/, true) {
16481650
if !bckFrom.IsRemote() {
16491651
p.writeErrf(w, r, "cannot %s bucket %q onto itself", msg.Action, bckFrom.Cname(""))
16501652
return

ais/test/etl_cp_multiobj_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,35 @@ func TestETLMultiObj(t *testing.T) {
6565
}
6666
}
6767

68+
func TestETLInspectMultiObj(t *testing.T) {
69+
tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
70+
71+
var (
72+
proxyURL = tools.RandomProxyURL(t)
73+
baseParams = tools.BaseAPIParams(proxyURL)
74+
bck = cmn.Bck{Name: "etl-inspect-objects-" + cos.GenUUID(), Provider: apc.AIS}
75+
m = ioContext{t: t, num: 10, fileSize: 512, fixedSize: true, bck: bck}
76+
)
77+
78+
tools.CreateBucket(t, proxyURL, bck, nil, true /*cleanup*/)
79+
m.init(true /*cleanup*/)
80+
m.puts()
81+
82+
initMsg := tetl.InitSpec(t, baseParams, tetl.MD5, etl.Hpush)
83+
t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, initMsg.Name()) })
84+
85+
msg := &cmn.TCOMsg{}
86+
msg.Transform.Name = initMsg.Name()
87+
msg.ListRange.ObjNames = m.objNames
88+
xid, err := api.ETLInspectMultiObj(baseParams, bck, msg)
89+
tassert.CheckFatal(t, err)
90+
tassert.Fatalf(t, msg.ToBck.IsEmpty() && !msg.DryRun && !msg.ContinueOnError, "inspect must not mutate caller message")
91+
92+
err = api.WaitForSnapsIdle(baseParams, &xact.ArgsMsg{ID: xid, Kind: apc.ActETLObjects})
93+
tassert.CheckFatal(t, err)
94+
checkObjectSizes(t, baseParams, bck, m.objNames, int64(m.fileSize))
95+
}
96+
6897
func testETLMultiObj(t *testing.T, etlName, prefix string, bckFrom cmn.Bck, fileRange, opType string, bcktest testBucketConfig, transform transformFunc) {
6998
pt, err := cos.ParseBashTemplate(fileRange)
7099
tassert.CheckFatal(t, err)

ais/test/etl_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,15 @@ func checkETLStats(t *testing.T, xid string, expectedObjCnt int, expectedBytesCn
355355
bytes, outBytes, inBytes)
356356
}
357357

358+
func checkObjectSizes(t *testing.T, bp api.BaseParams, bck cmn.Bck, objNames []string, size int64) {
359+
t.Helper()
360+
for _, objName := range objNames {
361+
oah, err := api.GetObjectWithValidation(bp, bck, objName, nil)
362+
tassert.CheckFatal(t, err)
363+
tassert.Fatalf(t, oah.Size() == size, "%s: expected size %d, got %d", bck.Cname(objName), size, oah.Size())
364+
}
365+
}
366+
358367
func TestETLObject(t *testing.T) {
359368
tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
360369
tetl.CheckNoRunningETLContainers(t, baseParams)
@@ -798,6 +807,34 @@ func TestETLBucketDryRun(t *testing.T) {
798807
checkETLStats(t, xid, m.num, uint64(m.num*int(m.fileSize)), false)
799808
}
800809

810+
func TestETLInspectBucket(t *testing.T) {
811+
tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
812+
tetl.CheckNoRunningETLContainers(t, baseParams)
813+
814+
var (
815+
proxyURL = tools.RandomProxyURL(t)
816+
baseParams = tools.BaseAPIParams(proxyURL)
817+
bck = cmn.Bck{Name: "etl-inspect-" + trand.String(5), Provider: apc.AIS}
818+
m = ioContext{t: t, num: 10, fileSize: 512, fixedSize: true, bck: bck}
819+
)
820+
821+
tools.CreateBucket(t, proxyURL, bck, nil, true /*cleanup*/)
822+
m.init(true /*cleanup*/)
823+
m.puts()
824+
825+
initMsg := tetl.InitSpec(t, baseParams, tetl.MD5, etl.Hpush)
826+
t.Cleanup(func() { tetl.StopAndDeleteETL(t, baseParams, initMsg.Name()) })
827+
828+
msg := &apc.TCBMsg{Transform: apc.Transform{Name: initMsg.Name()}}
829+
xid, err := api.ETLInspectBucket(baseParams, bck, msg)
830+
tassert.CheckFatal(t, err)
831+
tassert.Fatalf(t, !msg.DryRun && !msg.ContinueOnError, "inspect must not mutate caller message")
832+
833+
_, err = api.WaitForXactionIC(baseParams, &xact.ArgsMsg{ID: xid, Kind: apc.ActETLBck, Timeout: time.Minute})
834+
tassert.CheckFatal(t, err)
835+
checkObjectSizes(t, baseParams, bck, m.objNames, int64(m.fileSize))
836+
}
837+
801838
func TestETLStopAndRestartETL(t *testing.T) {
802839
tools.CheckSkip(t, &tools.SkipTestArgs{RequiredDeployment: tools.ClusterTypeK8s})
803840
tetl.CheckNoRunningETLContainers(t, baseParams)

api/bucket.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package api
66

77
import (
8+
"fmt"
89
"net/http"
910
"net/url"
1011
"strconv"
@@ -220,6 +221,17 @@ func ETLBucket(bp BaseParams, bckFrom, bckTo cmn.Bck, msg *apc.TCBMsg, fltPresen
220221
return tcb(bp, bckFrom, bckTo, jbody, fltPresence...)
221222
}
222223

224+
// ETLInspectBucket applies ETL to source objects without writing transformed results.
225+
func ETLInspectBucket(bp BaseParams, bck cmn.Bck, msg *apc.TCBMsg, fltPresence ...int) (string, error) {
226+
if msg == nil {
227+
return "", fmt.Errorf("%s: nil msg", apc.ActETLBck)
228+
}
229+
cmsg := *msg
230+
cmsg.DryRun = true
231+
cmsg.ContinueOnError = true
232+
return ETLBucket(bp, bck, bck, &cmsg, fltPresence...)
233+
}
234+
223235
func tcb(bp BaseParams, bckFrom, bckTo cmn.Bck, jbody []byte, fltPresence ...int) (string, error) {
224236
if err := bckTo.Validate(); err != nil {
225237
return "", err

api/multiobj.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package api
66

77
import (
8+
"fmt"
89
"net/http"
910
"strconv"
1011

@@ -58,6 +59,18 @@ func ETLMultiObj(bp BaseParams, bckFrom cmn.Bck, msg *cmn.TCOMsg, fltPresence ..
5859
return doBckAct(bp, bckFrom, jbody, q)
5960
}
6061

62+
// ETLInspectMultiObj applies ETL to source objects without writing transformed results.
63+
func ETLInspectMultiObj(bp BaseParams, bck cmn.Bck, msg *cmn.TCOMsg, fltPresence ...int) (string, error) {
64+
if msg == nil {
65+
return "", fmt.Errorf("%s: nil msg", apc.ActETLObjects)
66+
}
67+
cmsg := *msg
68+
cmsg.ToBck = bck
69+
cmsg.DryRun = true
70+
cmsg.ContinueOnError = true
71+
return ETLMultiObj(bp, bck, &cmsg, fltPresence...)
72+
}
73+
6174
func DeleteMultiObj(bp BaseParams, bck cmn.Bck, msg *apc.EvdMsg) (string, error) {
6275
bp.Method = http.MethodDelete
6376
q := qalloc()

docs/etl.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,39 @@ $ ais etl bucket ffmpeg-etl ais://libre-speech ais://libre-speech-transformed --
156156
$ ais ls ais://libre-speech-transformed | head -5
157157
```
158158

159+
### Object Inspection
160+
161+
Object inspection applies an ETL to a bucket or object group without writing
162+
transformed results. It is useful for validation jobs where the ETL logic either
163+
accepts the object or raises an error. After the job completes, use ETL error
164+
reporting to see exactly which objects failed.
165+
166+
Python SDK:
167+
168+
```python
169+
from aistore.sdk.etl.webserver.fastapi_server import FastAPIServer
170+
171+
etl = client.etl("validation-etl")
172+
173+
@etl.init_class()
174+
class ValidationETL(FastAPIServer):
175+
def transform(self, data: bytes, _path: str, _etl_args: str) -> bytes:
176+
if data == b"invalid":
177+
raise ValueError("validation failed")
178+
return data
179+
180+
job_id = bucket.objects(obj_names=["obj-1", "obj-2"]).inspect(etl_name=etl.name)
181+
errors = etl.view(job_id=job_id).obj_errors
182+
print(errors[0]) # obj_name='ais://test-bucket/obj-1' msg='ETL error: {"detail":"Processing error: validation failed"}'
183+
```
184+
185+
Go API:
186+
187+
```go
188+
xid, err := api.ETLInspectBucket(bp, bck, msg)
189+
xid, err := api.ETLInspectMultiObj(bp, bck, msg)
190+
```
191+
159192
### Single-Object Transformation
160193

161194
Single-object Transformation allows you to transform one object at a time between any two buckets. It's similar to a regular copy operation, but with an ETL transformation applied in-flight. This is ideal for quick, ad-hoc conversions where creating an entire new bucket isn’t necessary.

python/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ We structure this changelog in accordance with [Keep a Changelog](https://keepac
2424
- `Job.abort()` mirroring Go's `api.AbortXaction`: stops a job scoped by its
2525
`id` and/or `kind`. After aborting, `wait()` returns a `WaitResult` with
2626
`success=False` and the abort error instead of blocking until timeout.
27+
- ETL inspection APIs: `Bucket.inspect()` and `ObjectGroup.inspect()` run ETL
28+
in dry-run mode without writing transformed results, while preserving the
29+
existing ETL object-error reporting path.
2730

2831
### Changed
2932

python/aistore/sdk/bucket.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,41 @@ def transform(
951951
HTTP_METHOD_POST, ACT_ETL_BCK, value=value, params=params
952952
).text
953953

954+
def inspect(
955+
self,
956+
etl_name: str,
957+
timeout: str = DEFAULT_ETL_TIMEOUT,
958+
prefix_filter: str = "",
959+
latest: bool = False,
960+
num_workers: Optional[int] = None,
961+
etl_pipeline: Optional[List[str]] = None,
962+
) -> str:
963+
"""
964+
Apply ETL to source objects without writing transformed results.
965+
966+
Args:
967+
etl_name (str): Name of the ETL to run.
968+
timeout (str, optional): ETL request timeout.
969+
prefix_filter (str, optional): Only inspect objects with this prefix.
970+
latest (bool, optional): Use the latest remote object version.
971+
num_workers (Optional[int], optional): Number of workers per target.
972+
etl_pipeline (Optional[List[str]], optional): Ordered ETL pipeline.
973+
974+
Returns:
975+
Job ID (as str) that can be used to check the status of the operation.
976+
"""
977+
return self.transform(
978+
etl_name=etl_name,
979+
to_bck=self,
980+
timeout=timeout,
981+
prefix_filter=prefix_filter,
982+
latest=latest,
983+
dry_run=True,
984+
num_workers=num_workers,
985+
cont_on_err=True,
986+
etl_pipeline=etl_pipeline,
987+
)
988+
954989
def put_files(
955990
self,
956991
path: Union[str, Path],

python/aistore/sdk/multiobj/object_group.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,38 @@ def transform(
375375
HTTP_METHOD_POST, ACT_TRANSFORM_OBJECTS, value=value
376376
).text
377377

378+
def inspect(
379+
self,
380+
etl_name: str,
381+
timeout: str = DEFAULT_ETL_TIMEOUT,
382+
latest: bool = False,
383+
num_workers: Optional[int] = None,
384+
etl_pipeline: Optional[List[str]] = None,
385+
):
386+
"""
387+
Apply ETL to source objects without writing transformed results.
388+
389+
Args:
390+
etl_name (str): Name of the ETL to run.
391+
timeout (str, optional): ETL request timeout.
392+
latest (bool, optional): Use the latest remote object version.
393+
num_workers (Optional[int], optional): Number of workers per target.
394+
etl_pipeline (Optional[List[str]], optional): Ordered ETL pipeline.
395+
396+
Returns:
397+
Job ID (as str) that can be used to check the status of the operation.
398+
"""
399+
return self.transform(
400+
to_bck=self.bck,
401+
etl_name=etl_name,
402+
timeout=timeout,
403+
continue_on_error=True,
404+
dry_run=True,
405+
latest=latest,
406+
num_workers=num_workers,
407+
etl_pipeline=etl_pipeline,
408+
)
409+
378410
# pylint: disable=too-many-arguments,too-many-positional-arguments
379411
def archive(
380412
self,

python/tests/integration/sdk/test_etl_ops.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,67 @@ def test_init_spec_md5(self):
205205
# Verify bucket-level transformation and object-level transformation are the same
206206
self.assertEqual(obj, md5_obj)
207207

208+
@pytest.mark.etl
209+
def test_inspect(self):
210+
etl = self.client.etl(self.etl_name)
211+
212+
@etl.init_class()
213+
class MD5Server(FastAPIServer):
214+
def transform(self, data: bytes, *_args) -> bytes:
215+
return hashlib.md5(data).hexdigest().encode()
216+
217+
original = self.bucket.object(self.obj_name).get_reader().read_all()
218+
219+
job_id = self.bucket.inspect(etl_name=etl.name)
220+
result = self.client.job(job_id).wait_for_idle(timeout=TEST_TIMEOUT)
221+
self.assertTrue(result.success)
222+
self.assertEqual(
223+
original, self.bucket.object(self.obj_name).get_reader().read_all()
224+
)
225+
226+
job_id = self.bucket.objects(obj_names=[self.obj_name]).inspect(
227+
etl_name=etl.name
228+
)
229+
result = self.client.job(job_id).wait_for_idle(timeout=TEST_TIMEOUT)
230+
self.assertTrue(result.success)
231+
self.assertEqual(
232+
original, self.bucket.object(self.obj_name).get_reader().read_all()
233+
)
234+
235+
@pytest.mark.etl
236+
def test_inspect_reports_object_errors(self):
237+
obj_names = [f"inspect-{i}" for i in range(10)]
238+
for obj_name in obj_names:
239+
create_and_put_object(
240+
client=self.client,
241+
bck=self.bucket.as_model(),
242+
obj_name=obj_name,
243+
obj_size=self.obj_size,
244+
)
245+
failed_names = set(obj_names[1::2])
246+
247+
etl = self.client.etl(self.etl_name)
248+
249+
@etl.init_class()
250+
class ValidationServer(FastAPIServer):
251+
def transform(self, data: bytes, path: str, _etl_args: str) -> bytes:
252+
if path.rsplit("/", 1)[-1] in failed_names:
253+
raise ValueError("validation failed")
254+
return data
255+
256+
job_id = self.bucket.objects(obj_names=obj_names).inspect(etl_name=etl.name)
257+
result = self.client.job(job_id).wait_for_idle(timeout=TEST_TIMEOUT)
258+
self.assertTrue(result.success)
259+
260+
etl_details = etl.view(job_id=job_id)
261+
self.assertIsNotNone(etl_details.obj_errors)
262+
error_names = sorted(e.obj_name for e in etl_details.obj_errors)
263+
expected_errors = sorted(
264+
f"{self.bucket.provider.value}://{self.bucket.name}/{obj_name}"
265+
for obj_name in failed_names
266+
)
267+
self.assertEqual(error_names, expected_errors)
268+
208269
@pytest.mark.etl
209270
def test_init_spec_echo(self):
210271
# Start ETL with ECHO template

0 commit comments

Comments
 (0)