aster.Web Data / Docs Aster UI ↗

Aster Web Data / Operate

Limits

The caps on what one request may cost, their defaults, and what a client receives when a request goes over one.

One careless request, such as a sort on a column of another database over a large table, should not be able to exhaust the server's memory or hold a database connection for minutes. Aster Web Data caps the work a single request can cause, refuses a request that would go over a cap before it loads the database, and names the limit in the error. Every cap has a default that suits a typical service, and you can change most of them per service.

Request cost limits

Aster does not filter or sort a large table in memory. It sends a request's filter, sort and paging to the database, and refuses a request that would make the engine hold more rows than a cap allows. The cost limits set those caps.

Key Default What it caps
residual_rows 50,000 rows the engine holds at once, including rows a database returns for the engine to filter, sort or page
max_in_set 10,000 elements in one in list
max_batch_size 1,000 operations in one $batch request
max_insert_rows 10,000 rows in one insert
max_expand_depth 8 levels of nested $expand
max_traverse_depth 64 distance a hierarchy function may walk from its start
key_batch 8,192 keys in one statement of a key batch, such as the parent keys of an $expand; a larger level is read in chunks
max_orderby_items 128 items in one $orderby
max_lambda_depth 4 nesting of any and all in one expression
max_body_bytes 16 MiB size of a request body

Set the limits under limits, and a service can set its own under services.<name>.limits. A service's keys override the top-level ones, and the keys it leaves out keep the top-level value:

limits:
  residual_rows: 50000
  max_body_bytes: 4194304

services:
  reports:
    root: /reports
    datasources: [model/reports]
    limits:
      residual_rows: 100000

The limits come from three places, in this order: the built-in defaults, then the environment variables ASTER_WEBDATA_RESIDUAL_ROWS, ASTER_WEBDATA_MAX_INSERT_ROWS and ASTER_WEBDATA_MAX_QUEUE_OPS (which sets max_batch_size), then the configuration file. The file wins.

Three further limits are set in the engine and do not appear in the configuration file: an expression may nest 64 levels deep, a plan 256, and a hierarchy function may reach 50,000 keys and output 50,000 rows.

Nesting limits are checked as the request is parsed, so an over-deep expression or $expand is refused before it is planned.

Reads that would run in memory

A $skip or $count that the database cannot apply runs in the engine over every row the database returns. By default the server runs it and logs a warning, and counts it in aster_webdata_unbounded_residual_total. Set limits.unbounded_residual: refuse to refuse these requests instead.

Under refuse, the request fails with 501 NotImplemented, names the option and the set, and asks the client to leave that option out or use one the set can apply itself. The message does not say where the set is stored.

Timeouts

Two timeouts apply to every request:

Key Default Applies to
request_deadline_ms 60,000 the whole request, including the work the engine does after the database returns rows
statement_timeout_ms 30,000 each call to a database within the request

A request that runs past either timeout fails with 503 and the code Timeout. The engine checks the request deadline every 256 rows.

A $batch is one request: the deadline covers all of its members. A member that starts within it has what is left of it, and a member that would start after it is answered with 503 and the code Timeout without running.

The request deadline also bounds the client's side of a request. A body that has not fully arrived within it is refused with 408 and the code RequestTimeout. A client that stops reading a streamed response is cut off at the deadline: the server stops producing rows, and the body ends without completing.

Connections have timeouts of their own, which no setting changes:

Bound Value Applies to
Request head 30 s the time a request's head (its request line and headers) may take to arrive in full, and the time a keep-alive connection may wait for its next request
No traffic request_deadline_ms + 30 s a connection with no bytes either way for this long is closed (the longest request_deadline_ms of the services, taken at start-up)
HTTP/2 keep-alive 30 s ping, 20 s answer a connection whose peer does not answer a ping is closed

The database stops a statement that runs past statement_timeout_ms: PostgreSQL through its statement_timeout, set for each transaction, and MariaDB through max_statement_time on each write and each SELECT. SQLite and DuckDB interrupt the statement. A timed-out statement answers without a Retry-After header.

Page sizes

A service can cap the size of every page it returns:

Large reads

A read streams its rows. Aster builds the first page before it sends the status, so an error in the first page still answers with its proper status code. A failure after that point ends the response without its closing, so a client cannot mistake it for a complete answer, and the server logs the failure with the request's trace ID.

Counts

$count=true is always exact. The server reads the top page and runs COUNT(*) in one snapshot, so the count and the rows agree. On a very large set, a restrictive $filter keeps the count cheap, and an index on the filtered column lets the database answer it quickly.

What a refusal looks like

A refusal is an OData error object with a stable code, a message for people, and a target where one applies. This request sorts the invoices by their customer's name, which are stored on another database, with residual_rows set to 50 so that the engine would hold more rows than the limit allows:

GET /shop/Invoice
orderbycustomer/name
As a URLGET /shop/Invoice?$orderby=customer/name
HTTP 400
content-language: en
content-type: application/json;odata.metadata=minimal
odata-version: 4.01
{
  "error": {
    "code": "QueryTooExpensive",
    "message": "the request would hold more than 50 rows in memory to answer it. Filter, sort or page it so the database does the work, narrow the request, or raise `limits.residual_rows`"
  }
}

The codes a limit produces:

Status Code When
400 QueryTooExpensive a cost or depth limit was reached; the message names the limit
408 RequestTimeout the request body did not arrive within request_deadline_ms
413 PayloadTooLarge the request body is over max_body_bytes
501 NotImplemented an option cannot run where the rows are stored, for example $skip under unbounded_residual: refuse
503 Timeout a deadline or statement timeout was reached

For QueryTooExpensive, the client can push the filter, sort or page down to the database, or narrow the request. Filters, sorts and paging that the database applies keep rows out of the engine. The Error codes reference has every code. What runs on your database shows the SQL each request sends, so you can see which part of it the database runs.