> ## Content Index
> Fetch the complete content index at: https://www.mangocode.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# I saw a Go program fire twenty AI calls at once. Could I do that in PL/SQL?
- URL: https://www.mangocode.dev/i-saw-a-go-program-fire-twenty-ai-calls-at-once-could-i-do-that-in-pl-sql/
- Published: 2026-08-31T06:32:00.000Z
- Updated: 2026-09-17T20:10:07.000Z
- Description: PL/SQL has no asynchronous HTTP. Every REST call blocks its session for the whole round trip, so N calls at once means N sessions, and every way of getting more sessions in Oracle is job-based.
- Author: Raoul Mangoensentono

Someone posted a small Go tool called [imagine-cli](https://github.com/AhmedAburady/imagine-cli?ref=mangocode.dev). It generates images from the command line, and its selling point is that it does not do them one at a time. Pass -n 20, or hand it a YAML file of prompts, and it fires all twenty requests at the AI provider simultaneously. Twenty variations that used to take half an hour take about a minute.

Reading it, I had one thought: **could I do that from inside the Oracle database?**

---

**TL;DR:** PL/SQL has no asynchronous HTTP. Every REST call blocks its session for the whole round trip, so N calls at once means N sessions, and every way of getting more sessions in Oracle is job-based. DBMS\_SCHEDULER and DBMS\_PARALLEL\_EXECUTE both deliver about 6x on real embedding calls and are the same mechanism underneath. A parallel hint works too, at half the degree. Separately from that, DBMS\_VECTOR\_CHAIN.UTL\_TO\_EMBEDDING called inside a parallel hint hangs the session forever with no error, so call the provider through APEX\_WEB\_SERVICE and parse the response yourself. All numbers below.

---

I did not want to test with images. Generating twenty of them over and over while benchmarking would have cost more than the question was worth. But I already had an embedding package from an earlier article, hitting a real provider over REST, doing real work per row. Same shape, same problem, and cheap enough to run dozens of times.

I do this kind of work at Qualogy, building Oracle APEX applications and integrations for enterprise clients, which is where questions like "can the database do this itself" tend to come up in the first place.

So I built every mechanism Oracle offers for firing many REST calls at once, and measured them all against the same workload. The short version is that yes, you can, and it works well. But the answer to *why* it looks the way it does turned out to be more interesting than the speedups, and one combination hangs an Autonomous Database session permanently with no error message.

Here is all of it, with numbers.

---

### Why Oracle cannot do what Go does

Start with what the Go tool is actually doing, because it is the thing PL/SQL cannot copy.

Go's twenty concurrent requests live in one process. Each request runs on a goroutine, and while it waits for the provider to answer, the process gets on with the other nineteen. Waiting costs almost nothing, so twenty simultaneous calls cost roughly what one costs.

PL/SQL has no equivalent. UTL\_HTTP and APEX\_WEB\_SERVICE.MAKE\_REST\_REQUEST are synchronous, so they block the calling session for the whole round trip. There is no callback, no handle you can check back on later, and no non-blocking I/O to build one out of.

So this:

```
FOR d IN (SELECT doc_id FROM pq_docs ORDER BY doc_id) LOOP
    pkg_par_bench.do_item(l_run_id, d.doc_id, p_tier, 'SEQ');
END LOOP;
```

takes exactly as long as every call added together. One session, one request in flight, always. At 350ms per call and 195 rows, that is 68 seconds, and nothing you can write inside that loop will change it.

**You cannot have two REST calls in flight from one session.** Not "it is slow." There is no API for it.

Which means twenty requests at once needs twenty *sessions*. And in Oracle, every way of getting more sessions is job-based.

![Article content](https://media.licdn.com/dms/image/v2/D4E12AQEVz873jb80Kw/article-inline_image-shrink_1000_1488/B4EaBUkaIKJIAI-/0/1788125225929?e=1790812800&v=beta&t=diO_wBtaoqWn-qo27UdpFwqMtPPWES8ONj3NJpNfpHs)

concurrency-go-vs-oracle

That also explains something that had bothered me for years. I once found a DBMS\_SCHEDULER setup at work wrapped around a set of REST calls, with nothing actually being scheduled. No overnight window, no delay. Jobs created, run immediately, dropped. I assumed someone had over-complicated it.

They had not. It was not scheduling anything. It was buying concurrency, because jobs are the only lever the database gives you.

---

### What the call actually looks like

Before the mechanisms, the work being spread out. Every row does one of three kinds of work, chosen by tier so I could test the expensive path against free ones:

sql

```
CASE p_tier
    WHEN 'SLEEP' THEN
        DBMS_SESSION.SLEEP(c_sleep_seconds);

    WHEN 'HTTPBIN' THEN
        l_response := APEX_WEB_SERVICE.MAKE_REST_REQUEST(
            p_url         => c_httpbin_url,
            p_http_method => 'GET'
        );

    WHEN 'COHERE' THEN
        p_vector := pkg_ai.generate_embedding(p_chunk_text);
END CASE;
```

SLEEP costs nothing and tests session concurrency and nothing else. HTTPBIN hits httpbin.org/delay/2, a free public endpoint that waits N seconds before answering, so I get real TCP, real ACL setup, real network behaviour, and no rate limits. COHERE is the stand-in for imagine-cli's image calls: the same shape, an authenticated POST to a commercial AI provider with a few hundred milliseconds of waiting per row, at a fraction of the cost. The 195-row run costs pennies, which is what made it possible to run it dozens of times.

Cheapest first, so that when the expensive tier broke I had two working baselines to compare it against. That turned out to matter more than anything else in the project.

Every row logs itself when it finishes:

sql

```
INSERT INTO pq_run_items (
    run_id, doc_id, worker_tag, started_at, ended_at, status, ...
) VALUES (
    p_run_id, p_doc_id, p_worker_tag, l_started, SYSTIMESTAMP, ...
);
```

Nothing samples V$SESSION during a run. Concurrency is worked out afterwards in SQL by counting overlapping start and end intervals. Sampling would add load to the thing being measured and could miss a spike between two samples. Counting intervals afterwards can do neither.

---

### The four mechanisms

### 1\. Sequential, the baseline

The loop above. One session, one row at a time.

### 2\. DBMS\_SCHEDULER, create the jobs yourself

One job per worker, each handed a slice of the rows:

sql

```
FOR i IN 0 .. p_workers - 1 LOOP
    l_job_name := 'PQ_SCHED_' || l_run_id || '_' || i;
    DBMS_SCHEDULER.CREATE_JOB(
        job_name            => l_job_name,
        job_type            => 'STORED_PROCEDURE',
        job_action          => 'PKG_PAR_SCHED.RUN_CHUNK',
        number_of_arguments => 5,
        enabled             => FALSE,
        auto_drop           => TRUE
    );
    -- SET_JOB_ARGUMENT_VALUE for run_id, tier, worker_no, num_workers, limit
    DBMS_SCHEDULER.ENABLE(l_job_name);
END LOOP;
```

Each job takes every Nth row, so the work spreads out without the jobs needing to coordinate:

sql

```
SELECT doc_id FROM (
    SELECT doc_id,
           MOD(ROW_NUMBER() OVER (ORDER BY doc_id) - 1, p_num_workers) AS bucket
    FROM   pq_docs
)
WHERE bucket = p_worker_no
```

That round-robin split is better than giving each worker a contiguous range when your rows take different amounts of time, because long and short documents end up distributed evenly rather than all the long ones landing on worker 3.

### 3\. DBMS\_PARALLEL\_EXECUTE, let Oracle do the splitting

Same idea, much less code:

sql

```
DBMS_PARALLEL_EXECUTE.CREATE_TASK(l_task_name);

DBMS_PARALLEL_EXECUTE.CREATE_CHUNKS_BY_NUMBER_COL(
    task_name    => l_task_name,
    table_owner  => USER,
    table_name   => 'PQ_DOCS',
    table_column => 'DOC_ID',
    chunk_size   => l_chunk_size
);

DBMS_PARALLEL_EXECUTE.RUN_TASK(
    task_name      => l_task_name,
    sql_stmt       => 'BEGIN pkg_par_dbmspe.run_chunk(' || l_run_id
                       || ', ''' || p_tier || ''', :start_id, :end_id); END;',
    language_flag  => DBMS_SQL.NATIVE,
    parallel_level => p_workers
);
```

You also get per-chunk error status without writing it:

sql

```
SELECT COUNT(*) INTO l_error_count
FROM   user_parallel_execute_chunks
WHERE  task_name = l_task_name AND status = 'PROCESSED_WITH_ERROR';
```

**Chunk size and worker count are separate decisions, and it is worth being clear about why.** chunk\_size is how many rows go into one unit of work. parallel\_level is how many workers pull from the pile. Chunks are not handed out in advance. A worker grabs the next unclaimed chunk, finishes it, grabs another.

So you want more chunks than workers. Few big chunks means one slow chunk leaves everyone else idle at the end. Many small chunks means workers keep pulling new work and everyone finishes around the same time. Three to five chunks per worker is a reasonable starting point. My benchmark used exactly one chunk per worker, which is fine when every row takes the same time and wrong for real text of varying length.

### 4\. A parallel hint on the driving statement

The different one. Instead of jobs, this uses PX query slaves.

If you have not worked with them: when Oracle parallelises a large query, it does not do the work in your session. It starts a set of separate background processes, the parallel execution servers, usually shortened to PX slaves, hands each of them a portion of the rows, and your session becomes a coordinator that collects their results. It is the same feature that makes a big table scan faster on a multi-core machine. What is unusual here is using it to spread out network calls rather than disk reads.

sql

```
UPDATE /*+ parallel(d, 8) */ pq_docs d
SET    embedding = pkg_par_bench.do_item_sql(:1, d.doc_id, d.chunk_text, :2)
WHERE  d.doc_id <= :3;
```

Two things will silently defeat this before you get anywhere interesting.

Parallel DML needs enabling explicitly, or the driving SELECT goes parallel while the UPDATE runs serially, which looks exactly like the hint doing nothing:

sql

```
EXECUTE IMMEDIATE 'ALTER SESSION ENABLE PARALLEL DML';
```

And if there is no column to write into, forcing per-row evaluation is harder than it looks. COUNT(\*) over an inline view does not work: the optimizer works out that the column is never used and removes the function call. I watched this happen, with DOP 0, zero seconds, zero logged rows and no error. The statement ran perfectly and did nothing at all. BULK COLLECT into a collection forces real evaluation, because the caller clearly needs every value:

sql

```
EXECUTE IMMEDIATE
    'SELECT /*+ parallel(d, ' || l_dop || ') */
            pkg_par_bench.do_item_sql(:1, d.doc_id, d.chunk_text, :2)
     FROM   pq_docs d WHERE d.doc_id <= ' || l_limit
BULK COLLECT INTO l_vecs USING l_run_id, p_tier;
```

One more thing about mechanisms 2 and 3: they are the same thing underneath. Oracle's documentation says chunks are executed by DBMS\_SCHEDULER job slaves, and that running them in parallel requires CREATE JOB. Both draw on the same pool, capped by JOB\_QUEUE\_PROCESSES. Only the parallel hint uses different processes.

---

### Finding 1: UTL\_TO\_EMBEDDING inside a PX slave never returns

This is the one to take away.

Calling DBMS\_VECTOR\_CHAIN.UTL\_TO\_EMBEDDING from inside a PX slave does not run slowly. It does not finish. No error, no timeout, no ORA- anything. Nothing is written to my per-row log, because no row ever gets far enough to write one. The session sits there until you kill it by hand. I left one for 65 minutes.

Reproduced five times:

- Full 195-row workload, twice
- One single row through the real UPDATE path
- One single row through a SELECT with no DML at all, ruling out row locking
- All of it again with a different API key on a different provider plan

What makes this nasty is not that it is invisible, because a session running for an hour is easy to see. It is that **there is no failure to catch.** No exception reaches your handler. Nothing appears in your error log. Your retry logic never fires, because as far as PL/SQL is concerned the call simply has not come back yet. Every safety net you would normally rely on is waiting for an error that never arrives.

### It is the package, not the parallelism

The obvious explanation, that REST calls do not work from PX slaves, was already ruled out by the HTTPBIN tier, which ran cleanly at DOP 4 through the identical code path. But that is an unauthenticated GET to a test endpoint, which is not much of a match for an authenticated POST to a commercial API.

So I built a version that calls Cohere's embed endpoint directly through APEX\_WEB\_SERVICE, bypassing DBMS\_VECTOR\_CHAIN. Same PX-slave path, same PARALLEL\_ENABLE function under a parallel-hinted SELECT, same endpoint, same model, same key:

sql

```
FUNCTION do_item_sql_raw (
    p_run_id     IN NUMBER,
    p_doc_id     IN NUMBER,
    p_chunk_text IN CLOB
) RETURN VARCHAR2
PARALLEL_ENABLE
IS
    PRAGMA AUTONOMOUS_TRANSACTION;
    ...
BEGIN
    l_body := '{"texts":[' || APEX_JSON.STRINGIFY(DBMS_LOB.SUBSTR(p_chunk_text, 8000, 1)) || '],'
           || '"model":"' || c_cohere_embed_model || '",'
           || '"input_type":"search_document",'
           || '"embedding_types":["float"]}';

    APEX_WEB_SERVICE.SET_REQUEST_HEADERS(
        p_name_01 => 'Content-Type', p_value_01 => 'application/json', p_reset => TRUE);

    l_response := APEX_WEB_SERVICE.MAKE_REST_REQUEST(
        p_url                  => c_cohere_embed_url,
        p_http_method          => 'POST',
        p_body                 => l_body,
        p_credential_static_id => c_cohere_credential_static_id
    );
    ...
```

Result: **195 rows, 8 workers requested, DOP 4 achieved, 15.4 seconds, 195 out of 195 successful, zero errors.** Real embeddings, real concurrency, no hang, in exactly the shape that ran for 65 minutes through DBMS\_VECTOR\_CHAIN.

So the problem is not parallelism, not the network, not the ACL, not the provider, not the auth. It is DBMS\_VECTOR\_CHAIN.UTL\_TO\_EMBEDDING specifically, running inside a PX slave.

I have not found the root cause. V$SESSION and V$LOCK are not visible to a non-admin schema, so whether it is a lock, a latch, or something in how the package looks up its key inside a PX slave's restricted environment, I cannot say. I have one instance and cannot tell you whether it happens elsewhere.

**The workaround is the whole finding: if you want parallel embedding generation driven by a hint, call the provider through** APEX\_WEB\_SERVICE and parse the response yourself. You give up the convenience of UTL\_TO\_EMBEDDING and you get a job that finishes.

---

### Finding 2: the service you connect through decides your parallelism

Autonomous Database gives you five service names. Same 195-row workload, 8 workers requested every time, two separate runs:

ServiceRequestedAchieved DOPWall clocklow81390.1stp81390.0stpurgent8**8**52.1smedium84100.1shigh84100.1s

DOP here is the degree of parallelism, meaning how many PX slaves actually processed rows. I measured it by having every row record the session ID that handled it, then counting the distinct values.

Both runs agreed to a tenth of a second on every row. The numbers add up, which is how I know the measurement is sound:

- 195 rows × 2s, fully serial = **390.0s**. Measured 390.0 and 390.1.
- At DOP 8, the busiest worker gets ⌈195/8⌉ = 25 rows = **50.0s**. Measured 52.1, so about 2.1s of startup overhead.
- At DOP 4, ⌈195/4⌉ = 49 rows = **98.0s**. Measured 100.1, the same 2.1s.

The surprise is that tpurgent beats medium and high. [Oracle's service names documentation](https://docs.oracle.com/en-us/iaas/autonomous-database-shared/doc/service-names-tranaction-processing.html?ref=mangocode.dev) says medium and high run all operations in parallel subject to queuing, while tpurgent supports manual parallelism and tp and low do not run with parallelism at all.

I read medium and high as "these are the parallel ones." My results say otherwise. It looks like they apply their own calculation on top of the hint and settle on 4, while tpurgent takes the hint as given. I have not found that described anywhere, so treat the explanation as my guess and the table as the measurement.

### Why override Oracle's number at all?

Because Oracle is planning for a workload you do not have.

The optimizer's degree calculation assumes the work is CPU and I/O bound inside the database, so scanning blocks, sorting, hashing. On one OCPU, choosing 4 is sensible, because more workers would just compete for the same CPU.

But these rows are not CPU work. Each one is 350 milliseconds of a socket sitting idle waiting for a reply from Cohere. The workers are not competing for anything. Oracle's number is right for the workload it assumes and too low for the one you actually have, because it has no way to know your function spends its life blocked on a network call.

**For CPU-bound work, let Oracle choose. For work that is mostly waiting on somebody else's server, Oracle's number will be too low every time.** That is the case for connecting through tpurgent and stating the degree yourself.

---

### Finding 3: the job-based mechanisms just work

**SLEEP tier**, 195 rows, 8 workers requested:

MechanismAchievedWall clockSpeedupSequential1390.0s1.00xDBMS\_SCHEDULER850.4s7.73xDBMS\_PARALLEL\_EXECUTE851.5s7.58xParallel hint4104.0s3.75x

**HTTPBIN tier**, same shape under real network I/O:

MechanismAchievedWall clockSpeedupSequential1518.3s1.00xDBMS\_SCHEDULER870.4s7.39xDBMS\_PARALLEL\_EXECUTE869.4s7.45xParallel hint4137.3s3.81x

**COHERE tier**, real embedding calls, zero errors and zero retries:

MechanismAchievedWall clockSpeedupSequential168.7s1.00xDBMS\_SCHEDULER811.1s6.07xDBMS\_PARALLEL\_EXECUTE812.4s5.06xParallel hint via DBMS\_VECTOR\_CHAINn/a**never finished**n/aParallel hint via APEX\_WEB\_SERVICE415.4s4.46x

DBMS\_SCHEDULER and DBMS\_PARALLEL\_EXECUTE are indistinguishable here, which is what you would expect from two things sharing a job pool.

### More workers is not automatically better

Sweeping the requested worker count, on a reduced 40-row set to keep the API cost down across ten runs:

WorkersDBMS\_SCHEDULERDBMS\_PARALLEL\_EXECUTE112.26s14.25s28.03s9.27s44.06s6.05s8**2.12s3.09s**162.25s3.37s

Both got slower at 16 than at 8, even though both achieved the concurrency they asked for. At 40 rows split 16 ways that is about 2.5 rows per worker, which is not enough work to cover what each worker costs to start.

This is not a discovery, it is ordinary overhead. But it is worth measuring on your own data, because where it turns depends on rows per worker rather than on some magic number. Two separately written mechanisms turning at the same place is a good sign it is real.

---

### What it costs you

At the COHERE tier, sequential took 68.7 seconds using one session. Eight jobs took 11.1 seconds using eight sessions.

Multiply it out: 68.7 session-seconds against 88.8\. **Six times faster, for 29% more total session time.**

That number is the whole trade. The work did not get cheaper. It finished sooner because you occupied more of the database at once. Each of those eight sessions is a real server process sitting on a socket doing nothing, unavailable to anyone else, for as long as the call takes.

If the instance is yours, that is a good deal. If you share it, those are eight sessions somebody else does not have while your batch runs, and whoever owns Resource Manager may want a word. There is also a hard ceiling, because Always Free caps you at 20 concurrent sessions, so "add more workers" stops being an option sooner than you would think.

This is where the answer to the original question lands. Yes, you can do what the Go tool does from inside the database, and six times faster is a real result. But Go gets its twenty concurrent calls for free, and Oracle charges you twenty server processes for the same thing. If the work has to live next to your data, that is a price worth paying. If it does not, it is worth knowing you are paying it.

---

### What I would actually do

**Is your work row-shaped?** That decides most of it.

Row-shaped means a known set of rows, each independent, order irrelevant: 50,000 documents needing embeddings, a re-embed after changing model, geocoding every new customer record. You can point a chunking API at it.

Not row-shaped means requests arriving one at a time from different users, so five people each asking a question about their own data. There is no table to split. Creating a job per request means paying job startup on every question. That is a queue problem rather than a chunking problem, and it is what Part 2 is about.

**For row-shaped work, start with** DBMS\_PARALLEL\_EXECUTE:

sql

```
DBMS_PARALLEL_EXECUTE.CREATE_TASK(l_task);
DBMS_PARALLEL_EXECUTE.CREATE_CHUNKS_BY_NUMBER_COL(
    task_name => l_task, table_owner => USER,
    table_name => 'PQ_DOCS', table_column => 'DOC_ID',
    chunk_size => CEIL(l_doc_count / (p_workers * 4)));   -- ~4 chunks per worker
DBMS_PARALLEL_EXECUTE.RUN_TASK(
    task_name => l_task,
    sql_stmt  => 'BEGIN my_pkg.run_chunk(:start_id, :end_id); END;',
    language_flag => DBMS_SQL.NATIVE, parallel_level => p_workers);
DBMS_PARALLEL_EXECUTE.DROP_TASK(l_task);
```

**Drop to** DBMS\_SCHEDULER when the work is not rows, or when you want the round-robin split above because your rows vary a lot in cost.

**A parallel hint is fine for external calls.** The direct-REST version reached DOP 4 and 4.46x with no trouble. Connect through tpurgent if you want the degree you asked for. But **do not put** UTL\_TO\_EMBEDDING inside one. Call the provider yourself.

**Pick your worker count from your own data.** Run the sweep. Eight was right here, yours will differ.

---

### Part 2: the two I have not tested

DBMS\_AQ and APEX background execution chains are both built and both unmeasured, so I am not recommending either on theory alone. They get their own article once they have numbers.

Why they are interesting: AQ pulls from a durable queue instead of assigning row ranges up front, so a worker that dies does not take its work with it, which is the answer to the not-row-shaped case above. And APEX chains give you APEX\_BACKGROUND\_PROCESS.SET\_PROGRESS, live progress reporting that nothing else here provides.

---

### Limits of this test

One instance, 1 OCPU, one region, one embedding provider. The service-name result is two runs at a tier with no variance. The COHERE numbers are single runs at a tier whose latency does vary, which is why I report the two job-based mechanisms as equivalent rather than ranking them. The hang is reproduced five times, narrowed down once, and never explained.

Everything here is measured rather than assumed. That is not the same as everything here being general. If you reproduce any of it, especially the hang, I would like to hear about it.

**Have you ever had something fail by simply never finishing, with no error to catch and nothing in the log to tell you it went wrong?**

---

*Oracle Autonomous Database, 1 OCPU. Benchmark harness, per-mechanism packages and the full debug log available on request.*