Skip to content

feat(signage-ai): provider and job models (PPT-2741) - #326

Merged
stakach merged 4 commits into
masterfrom
feat/signage-ai
Aug 31, 2026
Merged

feat(signage-ai): provider and job models (PPT-2741)#326
stakach merged 4 commits into
masterfrom
feat/signage-ai

Conversation

@camreeves

@camreeves camreeves commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Part of the AI signage feature (PPT-2741). This is the bottom of the stack: everything else waits on it.

What this adds

Two models and the migration behind them.

SignageAIProvider holds a vendor configuration: which vendor, which model, the endpoint and region, whether it is enabled, which one is the default, and the per-user and per-domain quotas. Credentials are encrypted at rest and never come back out over the API. A provider can be scoped to a single domain or left global.

SignageAIJob is one generation or edit request and the images it produced. It carries the prompt, the state, the candidates, and enough accounting to answer "has this user run out" without a second table.

Notes for review

shard.yml is deliberately untouched so the version bump automation can pick the increment from the commit types. These are feat and fix, so it should land on 9.109.0.

Quota accounting counts failed jobs. That is intentional: a failed job still costs a vendor call, and not counting them turns a retry loop into a free pass. Claiming an image into the media library does not add to usage, since the job that produced it already did.

One thing worth a second opinion: the two enum columns are stored as TEXT with a CHECK constraint rather than a native Postgres ENUM. Every other PGEnumConverter column in this repo (17 of them) uses CREATE TYPE. TEXT plus CHECK is easier to extend later, but it is a third mechanism in a repo that had two, and I would rather that be a decision than an accident. Happy to convert it.

Retention (added after review feedback)

Steve asked for automatic cleanup of old job rows, so there is now a before_create callback that prunes past a retention window as each new job arrives.

It ships off. Nothing happens unless SIGNAGE_AI_CLEANUP_DAYS is set to a positive number of days, so an upgrade never starts deleting a site's history, and somewhere that generates seasonally keeps last year's work by doing nothing. Zero and negatives both read as "keep everything".

Two things in there worth a reviewer's attention.

The delete runs in a nested transaction, which PgORM issues as a SAVEPOINT. before_create runs inside the transaction that inserts the new row, so without it a failed cleanup would abort that insert and a user would fail to generate an image because housekeeping went wrong. A plain rescue does not help, since Postgres has already marked the transaction aborted.

It reads the ids and then deletes them, rather than the single statement this obviously wants to be:

DELETE FROM signage_ai_jobs WHERE id IN (
  SELECT id FROM signage_ai_jobs WHERE created_at < $1
  ORDER BY created_at LIMIT $2 FOR UPDATE SKIP LOCKED)

That does not reliably delete at most n rows. When the planner picks a nested loop semi join it re-runs the subquery once per candidate row, and because SKIP LOCKED and the rows already deleted by the statement change what comes back each time, the delete lands on the union of those evaluations. Confirmed with an audit trigger and auto_explain: a hash semi join runs the Limit once and removes 2 of 3, a nested loop runs it three times and removes all 3. The plan choice moves with table statistics, so it passes in psql and in tests until it does not.

Tested end to end against a local stack with the variable unset, at 90 days and at 30 days, plus the model specs and the 223 rest-api signage controller specs.

One caveat for operators turning it on: the usage report and the per domain monthly quota both read this table, so a window shorter than a month makes both under-count.

Merge order

This one first, and it needs to be tagged before rest-api can pin it. Then rest-api, then backoffice. ts-client, user-interfaces and placeos-docs have no ordering constraint.

Testing

Specs for both models, including the quota arithmetic and the guard against a job with no images array.

Creeves and others added 3 commits August 27, 2026 23:38
Two tables behind AI generated signage artwork:

- signage_ai_providers holds vendor credentials per domain, encrypted at
  rest and never rendered by the API. A row with no authority is the shared
  fallback, the arrangement storages already uses, and a trigger keeps one
  default per domain.
- signage_ai_jobs is one generate or edit request. Candidates are written
  into result->images one at a time by an atomic jsonb_set so concurrent
  writers cannot drop each other, and every write bumps a version counter
  for the long polling endpoint to compare against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ating usage

Both quota sums excluded failed jobs, so a caller whose requests kept failing
had no limit at all, which is the one case where a limit matters most. Most
failures reached the vendor and were billed.

`attach_item` records that a candidate became a media item without the
images_produced increment welded into `bump_image`. Claiming is not producing,
and counting it twice inflated the usage report by every save.
Copied from bump_image without the CASE that creates the array when it is
absent. Unreachable from today's callers, which only get there after matching
an existing entry, but the version would still bump while the write silently
did nothing.
Steve asked for automatic cleanup so a busy domain does not accumulate its
whole history. A before_create callback on the job, so it runs as each new
job arrives rather than needing a scheduler.

Off unless SIGNAGE_AI_CLEANUP_DAYS is set to a positive number of days.
Nothing starts deleting a site's history because it was upgraded, and
somewhere that generates seasonally keeps last year's work by doing nothing.
Zero and negatives both read as "keep everything" rather than as a cutoff in
the future that would take the whole table with it.

The delete runs in a nested transaction, which PgORM issues as a SAVEPOINT.
before_create runs inside the transaction that inserts the new row, so
without that a failed cleanup would abort the insert and a user would fail to
generate an image because tidying up went wrong. Rescuing alone is not
enough: Postgres has already marked the transaction aborted by then.

It reads the ids and then deletes them, rather than the one statement this
obviously wants to be:

    DELETE FROM signage_ai_jobs WHERE id IN (
      SELECT id FROM signage_ai_jobs WHERE created_at < $1
      ORDER BY created_at LIMIT $2 FOR UPDATE SKIP LOCKED)

That does not reliably delete at most n rows. When the planner picks a nested
loop semi join it re-runs the subquery once per candidate row, and since SKIP
LOCKED and the rows already deleted by the statement change what comes back
each time, the delete lands on the union of those evaluations. Confirmed with
an audit trigger and auto_explain: a hash semi join runs the Limit once and
removes 2 of 3, a nested loop runs it three times and removes all 3. Which
plan you get moves with the table statistics.

Work per insert is capped so a long disabled cleanup being switched on cannot
stall a request while it catches up.

One caveat for operators turning it on: the usage report and the per domain
monthly quota both read this table, so a window shorter than a month makes
both under-count.
@github-actions github-actions Bot added type: enhancement new feature or request and removed type: enhancement new feature or request labels Aug 31, 2026

@stakach stakach left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@stakach
stakach merged commit 7e07e8f into master Aug 31, 2026
8 of 9 checks passed
@stakach
stakach deleted the feat/signage-ai branch August 31, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: enhancement new feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants