A running structured streaming query: a server-side object, addressed by id.
Latu.write_stream/2 hands one back. The verbs here are one StreamingQueryCommand each,
keyed by the id and run id the struct carries, plus the four spark.streams verbs, which
take a session as Latu.Catalog's do. Inert data, like everything Latu hands out: no
process watches the query, and nothing stops it for you.
{:ok, query} =
Latu.write_stream(rollups,
format: "parquet", path: "/data/rollups", trigger: :available_now,
checkpoint_location: "/data/ckpt")
{:ok, true} = Latu.StreamingQuery.await_termination(query)
{:ok, progress} = Latu.StreamingQuery.last_progress(query)
:ok = Latu.StreamingQuery.stop(query)Three things a server measured that the types cannot say:
- A query outlives its client. Stop it, or it runs until the session ends; a lost handle
comes back through
get/2oractive/1.Latu.with_stream/3stops in anafter. - Failure is asynchronous. A query that dies at 3am raises nothing here. Whoever is in
await_termination/2gets the failure as{:error, _}; anyone else asksexception/1, or watchesevents/1for a:terminated. Latu.interrupt/2with no scope stops streaming queries too. By tag it reaches the ones started under that tag, which is the session-wide kill and a hazard for anyone interrupting a batch query in a session that also streams.
Progress and status are snake-cased maps of Spark's own JSON, which over Connect is lossy
against the driver's report: no top-level row counts, and a start_offset that is a string.
Sum :num_input_rows over :sources for the total. docs/deviations.md has the shape.
Times are milliseconds, as everywhere in Latu.
Summary
Types
One event off the listener bus: :type plus Spark's own JSON, snake-cased.
Which of Spark's three listener events this is, or :unknown for a fourth a newer server
invented. See event/0 for what each one carries.
One progress report, Spark's JSON with snake-cased keys. See last_progress/1.
The handle. name is nil when the query was started without one; run_id changes when a
query is restarted from its checkpoint, and the server refuses a command carrying the old one.
Functions
Every query running on the session, started by this client or not.
Like active/1, raising on failure.
Wait for any query on the session to end. await_termination/2's loop and options, over
the session.
Like await_any_termination/2, raising on failure.
Wait for the query to end.
Like await_termination/2, raising on failure.
Decode one progress report from the JSON the server sends, as last_progress/1 does.
Every streaming event on the session, as a lazy Stream.
Why the query stopped, if it failed: {:ok, nil} while it runs or after a clean stop, or
{:ok, %Latu.Error{kind: :query}} carrying Spark's error class and the server's stack trace.
Like exception/1, raising on failure.
Print the query's physical plan, as Latu.explain/2 does for a frame.
Like explain/2, raising on failure.
The query's physical plan as a string, where explain/2 prints it.
Like explain_string/2, raising on failure.
One active query by id, or nil. The answer carries the current run id, which is what
makes a handle recovered here usable where a remembered one may not be.
Like get/2, raising on failure.
Whether the query is still running.
Like is_active/1, raising on failure.
The most recent progress report, or nil before the first batch.
Like last_progress/1, raising on failure.
Block until every row the source has now has been processed.
Like process_all_available/1, raising on failure.
The recent progress reports, oldest first, as last_progress/1 decodes them.
Like recent_progress/1, raising on failure.
Forget the terminated queries await_any_termination/2 would otherwise answer for.
Like reset_terminated/1, raising on failure.
The query's status: its message, whether data is waiting, whether a trigger is running, and whether it is active at all.
Like status/1, raising on failure.
Stop the query. Idempotent on the server: stopping a query that has already terminated is a
no-op, which is what lets Latu.with_stream/3 stop in an after whatever happened.
Like stop/1, raising on failure.
Types
@type event() :: %{:type => event_type(), optional(atom()) => term()}
One event off the listener bus: :type plus Spark's own JSON, snake-cased.
:progress— carries:progress, exactly whatlast_progress/1returns.:idle—:id,:run_id,:timestamp.:terminated—:id,:run_id,:exception,:error_class_on_exception.:unknown— a type this client has not met, with:event_typeand the raw:jsonundecoded. A newer server can add one, and dropping the bus over it would be worse than handing it on.
@type event_type() :: :progress | :idle | :terminated | :unknown
Which of Spark's three listener events this is, or :unknown for a fourth a newer server
invented. See event/0 for what each one carries.
One progress report, Spark's JSON with snake-cased keys. See last_progress/1.
@type status() :: %{ message: String.t(), is_data_available: boolean(), is_trigger_active: boolean(), is_active: boolean() }
What status/1 answers.
@type t() :: %Latu.StreamingQuery{ id: String.t(), name: String.t() | nil, run_id: String.t(), session: Latu.Session.t() }
The handle. name is nil when the query was started without one; run_id changes when a
query is restarted from its checkpoint, and the server refuses a command carrying the old one.
Functions
@spec active(Latu.Session.t()) :: {:ok, [t()]} | {:error, Latu.Error.t()}
Every query running on the session, started by this client or not.
The recovery route: a query outlives the process that started it, and this is how the next process finds it.
@spec active!(Latu.Session.t()) :: [t()]
Like active/1, raising on failure.
@spec await_any_termination( Latu.Session.t(), keyword() ) :: {:ok, boolean()} | {:error, Latu.Error.t()}
Wait for any query on the session to end. await_termination/2's loop and options, over
the session.
Sticky, as Spark's is: once any query has terminated since the session started or since
reset_terminated/1, it answers {:ok, true} at once. A query that failed comes back as
{:error, _} carrying its exception, again as Spark's does.
Options
await_termination/2's: :timeout, :interval.
@spec await_any_termination!( Latu.Session.t(), keyword() ) :: boolean()
Like await_any_termination/2, raising on failure.
@spec await_termination( t(), keyword() ) :: {:ok, boolean()} | {:error, Latu.Error.t()}
Wait for the query to end.
{:ok, true} = Latu.StreamingQuery.await_termination(query)
{:ok, false} = Latu.StreamingQuery.await_termination(query, timeout: 30_000){:ok, true} once it has stopped, by stop/1 or by an :available_now trigger running out
of data; {:ok, false} when the timeout passes first. A query that failed comes back as
{:error, %Latu.Error{}} carrying its own exception, the server's awaitTermination
rethrowing it, so waiting is how a failure finds you.
The wait is a loop of bounded server waits, awaitTermination(interval) until it answers
true. A streaming command sends nothing while it waits, and a Connect server ends any silent
response stream every senderMaxStreamDuration, so one unbounded wait would hit Latu's
empty-reattach ceiling on a healthy query. Spark's semantics are kept; only the RPC is cut
into slices. docs/decisions.md has the measurement.
Options
:timeout— how long to wait in total, or:infinity. Defaults to:infinity.:interval— how long each server-side wait lasts. Defaults to10_000. Shorter means more round trips; longer means a singleExecutePlanidles longer, and only matters when it exceeds the server'ssenderMaxStreamDuration.
Like await_termination/2, raising on failure.
Decode one progress report from the JSON the server sends, as last_progress/1 does.
Every key becomes a snake-cased atom, except under start_offset, end_offset,
latest_offset and observed_metrics, whose values are left exactly as Spark wrote them:
an offset is a string for one source and a JSON object for another, and observed metrics
are keyed by the caller's own names. Spark's own key set is small and fixed, so the atoms
are bounded.
iex> Latu.StreamingQuery.decode_progress(~s({"batchId": 3, "durationMs": {"addBatch": 12},
...> "sources": [{"startOffset": "21", "numInputRows": 2}],
...> "observedMetrics": {"myMetric": {"rowCount": 2}}}))
%{
batch_id: 3,
duration_ms: %{add_batch: 12},
sources: [%{start_offset: "21", num_input_rows: 2}],
observed_metrics: %{"myMetric" => %{"rowCount" => 2}}
}
@spec events(Latu.Session.t()) :: Enumerable.t()
Every streaming event on the session, as a lazy Stream.
session
|> Latu.StreamingQuery.events()
|> Stream.filter(&(&1.type == :progress))
|> Enum.take(3)PySpark registers a listener object and calls back on a thread it owns; Latu hands you the
events and lets you decide where they run, as Latu.Progress does for a batch query. Nothing
is spawned: the bus opens when the stream is first enumerated, and closes when the
enumeration ends, however it ends.
Each element is a map with a :type — see event/0. A progress event's :progress is
what last_progress/1 answers, so decode_progress/1 is the whole decoder for both.
Four things to know, all of them the server's doing:
- One bus per session. The server answers a second
addwith a log line and nothing on the wire, so a secondevents/1on the same session hangs until the reattach guard gives up and then names this as the likely cause. Enumerate one bus per session at a time. - Closing is a command, not a release. Halting the stream sends
remove_listener_bus_listener; if that fails it is logged and the bus stays registered for the life of the session. ALatu.disconnect/2clears it either way. - Events are at-least-once. A reattach replays the responses the server still holds,
and neither Latu nor PySpark acknowledges an event, so a duplicate is possible. Match on
:run_idand:batch_idif that matters. - A silent bus is the normal state, so this is the one execution in Latu where an idle stream is not eventually an error. Before the first response it still is.
Raises Latu.Error on failure, as Latu.stream/2 does, since an enumeration has no way to
return one.
@spec exception(t()) :: {:ok, Latu.Error.t() | nil} | {:error, Latu.Error.t()}
Why the query stopped, if it failed: {:ok, nil} while it runs or after a clean stop, or
{:ok, %Latu.Error{kind: :query}} carrying Spark's error class and the server's stack trace.
The poll for a query nobody is waiting on. await_termination/2 gets the same failure as its
own {:error, _}.
@spec exception!(t()) :: Latu.Error.t() | nil
Like exception/1, raising on failure.
@spec explain( t(), keyword() ) :: :ok | {:error, Latu.Error.t()}
Print the query's physical plan, as Latu.explain/2 does for a frame.
Options
:mode—:simple(the default) or:extended. The streaming explain has two levels where the frame's has five; the spelling is the same so the two read alike.
Like explain/2, raising on failure.
@spec explain_string( t(), keyword() ) :: {:ok, String.t()} | {:error, Latu.Error.t()}
The query's physical plan as a string, where explain/2 prints it.
Options
explain/2's: :mode.
Like explain_string/2, raising on failure.
@spec get(Latu.Session.t(), String.t()) :: {:ok, t() | nil} | {:error, Latu.Error.t()}
One active query by id, or nil. The answer carries the current run id, which is what
makes a handle recovered here usable where a remembered one may not be.
@spec get!(Latu.Session.t(), String.t()) :: t() | nil
Like get/2, raising on failure.
@spec is_active(t()) :: {:ok, boolean()} | {:error, Latu.Error.t()}
Whether the query is still running.
Like is_active/1, raising on failure.
@spec last_progress(t()) :: {:ok, progress() | nil} | {:error, Latu.Error.t()}
The most recent progress report, or nil before the first batch.
{:ok, %{batch_id: 3, sources: [%{num_input_rows: 2}]}} =
Latu.StreamingQuery.last_progress(query)Spark's own JSON, keys snake-cased into atoms. The server keeps reports for a while after a query terminates, so this answers on a stopped query too.
Like last_progress/1, raising on failure.
@spec process_all_available(t()) :: :ok | {:error, Latu.Error.t()}
Block until every row the source has now has been processed.
A bounded-source verb: over a file directory it returns when the directory is drained, and
over a rate or Kafka source it never returns, because there is always more. One server call
with no timeout, so a long drain runs into the transport's empty-reattach ceiling
(Latu.Client) rather than being looped as await_termination/2 is. The deterministic test
shape is trigger: :available_now and await_termination/2 instead.
@spec process_all_available!(t()) :: :ok
Like process_all_available/1, raising on failure.
@spec recent_progress(t()) :: {:ok, [progress()]} | {:error, Latu.Error.t()}
The recent progress reports, oldest first, as last_progress/1 decodes them.
Like recent_progress/1, raising on failure.
@spec reset_terminated(Latu.Session.t()) :: :ok | {:error, Latu.Error.t()}
Forget the terminated queries await_any_termination/2 would otherwise answer for.
@spec reset_terminated!(Latu.Session.t()) :: :ok
Like reset_terminated/1, raising on failure.
@spec status(t()) :: {:ok, status()} | {:error, Latu.Error.t()}
The query's status: its message, whether data is waiting, whether a trigger is running, and whether it is active at all.
{:ok, %{message: "Waiting for data to arrive", is_active: true}} =
Latu.StreamingQuery.status(query)
Like status/1, raising on failure.
@spec stop(t()) :: :ok | {:error, Latu.Error.t()}
Stop the query. Idempotent on the server: stopping a query that has already terminated is a
no-op, which is what lets Latu.with_stream/3 stop in an after whatever happened.
@spec stop!(t()) :: :ok
Like stop/1, raising on failure.