tina4ruby 3.13.98 → 3.13.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -0
- data/lib/tina4/ai.rb +32 -3
- data/lib/tina4/api.rb +5 -0
- data/lib/tina4/auto_crud.rb +62 -4
- data/lib/tina4/background.rb +112 -31
- data/lib/tina4/cache.rb +3 -2
- data/lib/tina4/cli.rb +55 -67
- data/lib/tina4/database.rb +97 -49
- data/lib/tina4/database_adapter.rb +169 -15
- data/lib/tina4/dev_admin.rb +137 -9
- data/lib/tina4/dispatch_pipeline.rb +145 -4
- data/lib/tina4/drivers/firebird_driver.rb +59 -12
- data/lib/tina4/drivers/mongodb_driver.rb +98 -14
- data/lib/tina4/drivers/mssql_driver.rb +39 -2
- data/lib/tina4/drivers/mysql_driver.rb +43 -3
- data/lib/tina4/drivers/odbc_driver.rb +36 -2
- data/lib/tina4/drivers/postgres_driver.rb +5 -0
- data/lib/tina4/drivers/sqlite_driver.rb +11 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/error_overlay.rb +43 -49
- data/lib/tina4/field_types.rb +33 -16
- data/lib/tina4/frond.rb +24 -2
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
- data/lib/tina4/graphql.rb +2 -2
- data/lib/tina4/log.rb +652 -485
- data/lib/tina4/mcp.rb +9 -1
- data/lib/tina4/messenger.rb +25 -0
- data/lib/tina4/middleware.rb +189 -76
- data/lib/tina4/migration.rb +47 -15
- data/lib/tina4/orm.rb +280 -59
- data/lib/tina4/port_takeover.rb +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
- data/lib/tina4/rack_app.rb +201 -59
- data/lib/tina4/realtime.rb +6 -1
- data/lib/tina4/request.rb +259 -51
- data/lib/tina4/router.rb +20 -2
- data/lib/tina4/seeder.rb +68 -19
- data/lib/tina4/shutdown.rb +4 -0
- data/lib/tina4/sql_translator.rb +115 -86
- data/lib/tina4/swagger.rb +19 -3
- data/lib/tina4/template.rb +61 -6
- data/lib/tina4/test_client.rb +49 -3
- data/lib/tina4/testing.rb +16 -11
- data/lib/tina4/validator.rb +7 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -40
- data/lib/tina4.rb +12 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11d1350ed25f20cfc7c7ba9757417c0eb33e87292eb45876d3bed30119fefcca
|
|
4
|
+
data.tar.gz: 8bd0d33d8ae49492bd8784fb838339951ad92439bf96a615d71ffe2e488dfd7a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e24bac573c4d4b58642fdc65071857c4a910dac229057f92c9eca2ba0936c1163a6e89bc79452c82f7d4601a4e64b0c10ce5c0af3e35384c16344491c6f61203
|
|
7
|
+
data.tar.gz: 0afe568dfd38eee2c0ce7c507b71b8c0110e11be10cce5197100244d30e56ac58e119b25a1e5757df8b26a9bb74172b4739d1e94ef029729e7b3e932e2847032
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,90 @@ number means the same thing everywhere.
|
|
|
6
6
|
**The authoritative release notes for every shipped version live in the documentation:**
|
|
7
7
|
https://tina4.com/ruby/36-releases
|
|
8
8
|
|
|
9
|
+
### Breaking: `request.params` is route-params-only, and `path_params` is renamed `params`
|
|
10
|
+
|
|
11
|
+
Ruby had the worst version of the param-pollution bug: `params` used to merge the query
|
|
12
|
+
string, the parsed body, AND the route params, so `request.params["id"]` could silently
|
|
13
|
+
return a client-supplied value that shadowed the real route parameter. Client input now
|
|
14
|
+
lives only in `request.query` and `request.body`. **The route-param accessor itself is
|
|
15
|
+
renamed: `path_params` becomes `params`, with no alias.** A malformed JSON body used to parse
|
|
16
|
+
to `{}`; it now returns the raw string it failed to parse. An empty body used to parse to
|
|
17
|
+
`{}`; it now returns `nil`. `header()` lookup is case-fold only now (it no longer also
|
|
18
|
+
converts `_` to `-`).
|
|
19
|
+
|
|
20
|
+
**Migration.** Rename every `path_params` call site to `params`. Replace any `params[...]`
|
|
21
|
+
read of a client-supplied value with `query[...]` or `body[...]`.
|
|
22
|
+
|
|
23
|
+
### Breaking: security headers, CSRF, and the dev server default on
|
|
24
|
+
|
|
25
|
+
`Content-Security-Policy: default-src 'self'` and the other security headers now emit by
|
|
26
|
+
default (relax with `TINA4_CSP`; HSTS on HTTPS via `TINA4_HSTS`). The CSRF `403` body is
|
|
27
|
+
unified to `{error, code, message, status}`, where Ruby used to send
|
|
28
|
+
`{error: "CSRF_INVALID"}`. `TINA4_CSRF=true` now actually attaches the CSRF middleware, and a
|
|
29
|
+
blank `TINA4_SECRET` fails closed instead of minting a forgeable public-default token. The
|
|
30
|
+
dev server binds `127.0.0.1` by default (`TINA4_HOST=0.0.0.0` to expose it), refuses a
|
|
31
|
+
cross-origin `/__dev` mutation, and never serves `.env` through the file endpoints. Static
|
|
32
|
+
asset serving drops the `src/assets`/`assets` search directories and now honours
|
|
33
|
+
`TINA4_PUBLIC_DIR`. A reflected XSS in the `403` error page is closed
|
|
34
|
+
(`CGI.escapeHTML`-escaped now).
|
|
35
|
+
|
|
36
|
+
**Migration.** Move assets under the configured public directory. Set `TINA4_CSP` if you
|
|
37
|
+
depend on inline scripts or a third-party CDN.
|
|
38
|
+
|
|
39
|
+
### Breaking: Mongo, MSSQL, and file-upload footguns closed
|
|
40
|
+
|
|
41
|
+
An unparseable/unsupported MongoDB WHERE now raises instead of silently matching every
|
|
42
|
+
document (a DELETE/UPDATE with no WHERE is rejected); `truncate()` on Mongo now actually
|
|
43
|
+
empties the collection. The MSSQL adapter now raises on a genuinely unbindable parameter type
|
|
44
|
+
instead of silently stringifying it (was preventing an injection/corruption risk). A repeated
|
|
45
|
+
multipart file field now yields a list instead of silently dropping every upload but the
|
|
46
|
+
last; an over-limit upload now answers `413` mid-stream instead of after buffering the whole
|
|
47
|
+
body. Frond `{% include %}`/`{% extends %}`/`{% import %}` now raise on a path that escapes
|
|
48
|
+
the templates directory.
|
|
49
|
+
|
|
50
|
+
**Migration.** Add an explicit WHERE to any Mongo query relying on the old match-all
|
|
51
|
+
fallback, or call `truncate()`. Handle `request.files[x]` as a list when multiple files can
|
|
52
|
+
share a field name.
|
|
53
|
+
|
|
54
|
+
### Breaking: ORM write-path and AutoCrud parity fixes
|
|
55
|
+
|
|
56
|
+
`decimal_field` now emits a real `DECIMAL(p,s)` column instead of `REAL`, dropping precision
|
|
57
|
+
and scale. A foreign-key auto `related_name` is now smart-pluralized (`Category` ->
|
|
58
|
+
`categories`, not `categorys`). `validate()` on save now enforces length/type/format, where it
|
|
59
|
+
used to check only for `null` -- a model that previously saved an over-length or
|
|
60
|
+
wrong-format value now returns `false` from `save()` and writes nothing. `create_table()`
|
|
61
|
+
injects the configured `soft_delete_field` automatically. `load()`'s signature changes to
|
|
62
|
+
`load(filter, params, include)`, from `load(arg, params)` with no `include`, and it now
|
|
63
|
+
JSON-coerces json columns. AutoCrud returns `422` (was `500`) on an invalid create/update, and
|
|
64
|
+
never accepts `is_deleted` or a client-supplied primary key in the write body. `seed_table`'s
|
|
65
|
+
`seed:` keyword is dropped, `FakeData#boolean` returns a native `true`/`false` (was `0`/`1`),
|
|
66
|
+
and `seed_orm`'s idempotency skip is opt-in now via `idempotent:` (was unconditional -- it
|
|
67
|
+
silently returned `seeded: 0` when the table already held enough rows).
|
|
68
|
+
|
|
69
|
+
**Migration.** Update any accessor using the misspelled `categorys` name. Pass the RNG seed to
|
|
70
|
+
your own `FakeData` instance instead of `seed_table(seed: ...)`. Update any `load()` call
|
|
71
|
+
using the old positional shape.
|
|
72
|
+
|
|
73
|
+
### Breaking: response, database-adapter, and dev-tooling fixes
|
|
74
|
+
|
|
75
|
+
Responses gzip-compress when eligible; a cacheable 200 gets a strong ETag, and the
|
|
76
|
+
static-file ETag format is unified to `W/"<size>-<mtime>"` across all four frameworks. Error
|
|
77
|
+
pages can emit JSON now, not only HTML: `403`/`404`/`500` all negotiate `Accept`, and `404`
|
|
78
|
+
carries a `request_id`. An undecorated route emits only `200` in the OpenAPI spec;
|
|
79
|
+
`description` is omitted when unset instead of `description:""`. A route group's prefix join
|
|
80
|
+
is normalized to match PHP. `tina4ruby serve` now honours `TINA4_PORT`, where it used to read
|
|
81
|
+
only the bare `PORT` variable. `serverInfo.version` over MCP now reports the real framework
|
|
82
|
+
version instead of `1.0.0`. The inline `@tests` descriptor builders are renamed
|
|
83
|
+
`Tina4::Testing.assert_*` -> `expect_*`. `DatabaseAdapter::CONTRACT` was fictional (it named
|
|
84
|
+
methods no driver implemented, and omitted `execute_many`/`fetch_one`); `get_database_type`
|
|
85
|
+
existed on none of the seven drivers and is added to all of them. A legacy bracket-wrapped
|
|
86
|
+
log-level spelling that duplicated the variable name inside the brackets is rejected now; use
|
|
87
|
+
the plain level name (for example `TINA4_LOG_LEVEL=ALL`).
|
|
88
|
+
|
|
89
|
+
**Migration.** Rename any `assert_*` descriptor call to `expect_*`. Reconcile `TINA4_PORT` vs
|
|
90
|
+
bare `PORT` if your app set both. Expect every cache to revalidate once after upgrade, since
|
|
91
|
+
the static-file ETag format changed.
|
|
92
|
+
|
|
9
93
|
### Breaking: Messenger `inbox()` / `read()` item shapes (3.13.96 parity)
|
|
10
94
|
|
|
11
95
|
The IMAP read path is aligned to the settled cross-framework shape (Python is the
|
data/lib/tina4/ai.rb
CHANGED
|
@@ -70,10 +70,21 @@ module Tina4
|
|
|
70
70
|
# Fetch the bytes at `url`, following up to `limit` redirects. Returns
|
|
71
71
|
# nil on any network/HTTP failure so the caller can skip gracefully.
|
|
72
72
|
#
|
|
73
|
+
# MEASURED 2026-08-13 on the real suite (not a mock): install_skills
|
|
74
|
+
# makes up to 18 sequential HTTPS calls (3 skills x 2 targets x
|
|
75
|
+
# (1 SKILL.md + refs)) to raw.githubusercontent.com, and a single
|
|
76
|
+
# transient timeout/reset on any one of them silently dropped that
|
|
77
|
+
# whole skill - `installed` came back missing "tina4-maintainer" with
|
|
78
|
+
# no other symptom. A short retry on the TRANSPORT-level failure modes
|
|
79
|
+
# only (never on a clean HTTP response, which is a real answer, not a
|
|
80
|
+
# glitch) is the fix a real end user running `tina4 ai` needs too, not
|
|
81
|
+
# just this suite.
|
|
82
|
+
#
|
|
73
83
|
# @param url [String]
|
|
74
84
|
# @param limit [Integer] max redirects to follow
|
|
85
|
+
# @param retries [Integer] transient-failure retries before giving up
|
|
75
86
|
# @return [String, nil]
|
|
76
|
-
def fetch_bytes(url, limit = 5)
|
|
87
|
+
def fetch_bytes(url, limit = 5, retries = 2)
|
|
77
88
|
return nil if limit <= 0
|
|
78
89
|
|
|
79
90
|
uri = URI.parse(url)
|
|
@@ -85,15 +96,33 @@ module Tina4
|
|
|
85
96
|
response = http.get(uri.request_uri)
|
|
86
97
|
case response
|
|
87
98
|
when Net::HTTPSuccess
|
|
88
|
-
response.body
|
|
99
|
+
return response.body
|
|
89
100
|
when Net::HTTPRedirection
|
|
90
101
|
location = response["location"]
|
|
91
|
-
location ? fetch_bytes(location, limit - 1) : nil
|
|
102
|
+
return location ? fetch_bytes(location, limit - 1, retries) : nil
|
|
92
103
|
end
|
|
104
|
+
|
|
105
|
+
# Not a success or a redirect. A throttle/server hiccup (429 rate
|
|
106
|
+
# limit, 502/503/504) is worth one retry; anything else (404, a real
|
|
107
|
+
# 4xx) is a genuine answer - retrying would not change it.
|
|
108
|
+
return fetch_and_retry(url, limit, retries) if retries.positive? && TRANSIENT_HTTP_CODES.include?(response.code)
|
|
109
|
+
|
|
110
|
+
nil
|
|
111
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED, SocketError, EOFError
|
|
112
|
+
return nil unless retries.positive?
|
|
113
|
+
|
|
114
|
+
fetch_and_retry(url, limit, retries)
|
|
93
115
|
rescue StandardError
|
|
94
116
|
nil
|
|
95
117
|
end
|
|
96
118
|
|
|
119
|
+
TRANSIENT_HTTP_CODES = %w[429 500 502 503 504].freeze
|
|
120
|
+
|
|
121
|
+
def fetch_and_retry(url, limit, retries)
|
|
122
|
+
sleep(0.5)
|
|
123
|
+
fetch_bytes(url, limit, retries - 1)
|
|
124
|
+
end
|
|
125
|
+
|
|
97
126
|
# Install the Tina4 SKILL.md skills into the project AND the user's
|
|
98
127
|
# global ~/.claude/skills, network-fetched from the release tag matching
|
|
99
128
|
# this framework version. Returns the skills fully installed.
|
data/lib/tina4/api.rb
CHANGED
|
@@ -107,7 +107,12 @@ module Tina4
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
@base_url = base_url.chomp("/")
|
|
110
|
+
# VERSION-DEC-03 (feature 130): every outbound request carries a
|
|
111
|
+
# default `Tina4/<version>` User-Agent. `headers` (the caller's own,
|
|
112
|
+
# from the constructor kwarg) is merged in LAST, so a caller-supplied
|
|
113
|
+
# "User-Agent" always wins -- this is a default, never a clobber.
|
|
110
114
|
@headers = {
|
|
115
|
+
"User-Agent" => "Tina4/#{Tina4::VERSION}",
|
|
111
116
|
"Content-Type" => "application/json",
|
|
112
117
|
"Accept" => "application/json"
|
|
113
118
|
}.merge(headers)
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -3,6 +3,14 @@ require "json"
|
|
|
3
3
|
|
|
4
4
|
module Tina4
|
|
5
5
|
module AutoCrud
|
|
6
|
+
# PAGE-DEC-01: the maximum per-page size the list handler will honour, no
|
|
7
|
+
# matter what a caller asks for via ?limit=/?per_page=. 100 is not an
|
|
8
|
+
# arbitrary pick - it is the SAME row cap ORM.all/db.fetch already default
|
|
9
|
+
# to, and the number Node's AutoCrud shares via its own DEFAULT_ROW_CAP
|
|
10
|
+
# constant. Without this a client could request the whole table in one
|
|
11
|
+
# query (?limit=1000000).
|
|
12
|
+
MAX_PER_PAGE = 100
|
|
13
|
+
|
|
6
14
|
class << self
|
|
7
15
|
# Track registered model classes
|
|
8
16
|
def models
|
|
@@ -85,7 +93,16 @@ module Tina4
|
|
|
85
93
|
Tina4::Router.add("GET", "#{prefix}/#{table}", proc { |req, res|
|
|
86
94
|
begin
|
|
87
95
|
per_page = (req.query["per_page"] || req.query["limit"] || 10).to_i
|
|
96
|
+
# PAGE-DEC-01: cap an oversized ?per_page=/?limit= BEFORE it is used to
|
|
97
|
+
# derive offset below, so the offset lines up with the size actually
|
|
98
|
+
# used (a client can no longer request the whole table in one query).
|
|
99
|
+
per_page = [per_page, MAX_PER_PAGE].min
|
|
88
100
|
page = (req.query["page"] || 1).to_i
|
|
101
|
+
# PAGE-DEC-01: clamp page < 1 -> page 1 BEFORE deriving offset, so
|
|
102
|
+
# offset=(page-1)*per_page can never go negative (page=0/negative used
|
|
103
|
+
# to hand PostgreSQL a negative OFFSET - a driver error - and silently
|
|
104
|
+
# misbehave on SQLite, while the envelope reported page:0).
|
|
105
|
+
page = [page, 1].max
|
|
89
106
|
limit = per_page
|
|
90
107
|
offset = req.query["offset"] ? req.query["offset"].to_i : (page - 1) * per_page
|
|
91
108
|
order_by = parse_sort(req.query["sort"])
|
|
@@ -149,9 +166,17 @@ module Tina4
|
|
|
149
166
|
# POST /api/{table} -- create record
|
|
150
167
|
post_route = Tina4::Router.add("POST", "#{prefix}/#{table}", proc { |req, res|
|
|
151
168
|
begin
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
# CRUD-MASS-ASSIGNMENT: allow-list before the body ever reaches
|
|
170
|
+
# the model (guards is_deleted + strips the PK -- see the helper).
|
|
171
|
+
attributes = allow_listed_attributes(model_class, req.body_parsed, is_create: true)
|
|
172
|
+
record = model_class.new(attributes)
|
|
173
|
+
# CRUD-VALIDATION-STATUS (CRUD-DEC-01): build + save directly
|
|
174
|
+
# (not .create, which returns the literal `false` on failure --
|
|
175
|
+
# calling .persisted? on that raised NoMethodError, caught by the
|
|
176
|
+
# generic rescue below as a stray 500). #save already returns
|
|
177
|
+
# self/false and #errors already carries the field messages, so
|
|
178
|
+
# this is the SAME safe pattern the PUT handler uses.
|
|
179
|
+
if record.save
|
|
155
180
|
res.json({ data: record.to_h }, status: 201)
|
|
156
181
|
else
|
|
157
182
|
res.json({ errors: record.errors }, status: 422)
|
|
@@ -181,7 +206,9 @@ module Tina4
|
|
|
181
206
|
next res.json({ error: "Not found" }, status: 404)
|
|
182
207
|
end
|
|
183
208
|
|
|
184
|
-
|
|
209
|
+
# CRUD-MASS-ASSIGNMENT: allow-list -- the row is addressed by the
|
|
210
|
+
# URL {id}, never by the body (see the helper).
|
|
211
|
+
attributes = allow_listed_attributes(model_class, req.body_parsed, is_create: false)
|
|
185
212
|
attributes.each do |key, value|
|
|
186
213
|
setter = "#{key}="
|
|
187
214
|
record.__send__(setter, value) if record.respond_to?(setter)
|
|
@@ -266,6 +293,37 @@ module Tina4
|
|
|
266
293
|
|
|
267
294
|
private
|
|
268
295
|
|
|
296
|
+
# CRUD-MASS-ASSIGNMENT: filter a write body down to writable columns
|
|
297
|
+
# before it ever reaches `.new`/the setter loop. Only DECLARED fields
|
|
298
|
+
# (model_class.field_definitions) pass through; is_deleted is never
|
|
299
|
+
# client-writable (soft-delete is mutated only by #delete/#restore);
|
|
300
|
+
# and the primary key is stripped except a genuinely natural
|
|
301
|
+
# (single-column, non-auto_increment) key on CREATE -- the documented
|
|
302
|
+
# way to choose one (build_example keeps such a key in the sample
|
|
303
|
+
# body). Every other case strips it: an auto-increment CREATE (the
|
|
304
|
+
# database assigns it -- a client-supplied id previously let a POST
|
|
305
|
+
# silently claim/overwrite an unrelated row), and EVERY update (the
|
|
306
|
+
# row is addressed by the URL {id} alone; a body PK would otherwise
|
|
307
|
+
# move #save's own pk_filter WHERE clause off the URL-addressed row).
|
|
308
|
+
def allow_listed_attributes(model_class, data, is_create:)
|
|
309
|
+
return {} unless data.is_a?(Hash)
|
|
310
|
+
|
|
311
|
+
defs = model_class.respond_to?(:field_definitions) ? model_class.field_definitions : {}
|
|
312
|
+
pk_fields = model_class.respond_to?(:primary_key_fields) ? model_class.primary_key_fields.map(&:to_s) : []
|
|
313
|
+
single_pk = pk_fields.length == 1 ? pk_fields.first : nil
|
|
314
|
+
auto_increment = single_pk && defs[single_pk.to_sym] && defs[single_pk.to_sym][:auto_increment]
|
|
315
|
+
strip_pk = is_create ? !(single_pk && !auto_increment) : true
|
|
316
|
+
|
|
317
|
+
data.each_with_object({}) do |(key, value), allowed|
|
|
318
|
+
key_s = key.to_s
|
|
319
|
+
next unless defs.key?(key_s.to_sym)
|
|
320
|
+
next if key_s == "is_deleted"
|
|
321
|
+
next if strip_pk && pk_fields.include?(key_s)
|
|
322
|
+
|
|
323
|
+
allowed[key] = value
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
269
327
|
# Parse sort parameter: "-name,created_at" => "name DESC, created_at ASC"
|
|
270
328
|
def parse_sort(sort_str)
|
|
271
329
|
return nil if sort_str.nil? || sort_str.empty?
|
data/lib/tina4/background.rb
CHANGED
|
@@ -7,37 +7,110 @@ module Tina4
|
|
|
7
7
|
# PHP's `$app->background($callback, $interval)` — a callback that runs
|
|
8
8
|
# periodically alongside the server lifecycle.
|
|
9
9
|
#
|
|
10
|
-
# Ruby has no asyncio event loop, so each task runs in its own
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
10
|
+
# Ruby has no asyncio event loop, so each task runs in its own dedicated OS
|
|
11
|
+
# thread, started at registration time. Because the thread runs regardless of
|
|
12
|
+
# which web server (Puma/WEBrick) is in front, a Ruby background task is never
|
|
13
|
+
# a silent no-op under production — the thread IS the runtime (contrast the
|
|
14
|
+
# Python ASGI / PHP-FPM silent-no-op the other frameworks had to fix). The GIL
|
|
15
|
+
# keeps it cooperative-enough for the periodic work this is meant for (queue
|
|
16
|
+
# draining, health checks, simulators). Errors in the callback are caught and
|
|
17
|
+
# logged so they don't kill the thread.
|
|
14
18
|
module Background
|
|
19
|
+
# Handle for one registered background task — the ONE background surface,
|
|
20
|
+
# identical across the four frameworks: a handle with a boolean `stop` plus a
|
|
21
|
+
# count. Mirrors Python's `BackgroundTask` (`handle.stop()`), PHP's
|
|
22
|
+
# `Tina4\BackgroundTask` (`$handle->stop()`) and Node's `background()` handle
|
|
23
|
+
# (`handle.stop()`).
|
|
24
|
+
#
|
|
25
|
+
# It also answers `[:callback]`/`[:interval]`/`[:thread]`/`[:running]` (read
|
|
26
|
+
# AND write) so a descriptor and a handle are the same object — code that
|
|
27
|
+
# introspected the old Hash descriptor keeps working unchanged.
|
|
28
|
+
class Task
|
|
29
|
+
attr_accessor :callback, :interval, :thread, :running
|
|
30
|
+
|
|
31
|
+
def initialize(callback, interval)
|
|
32
|
+
@callback = callback
|
|
33
|
+
@interval = interval
|
|
34
|
+
@thread = nil
|
|
35
|
+
@running = false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Stop this task and DEREGISTER it. Idempotent — a second call is a safe
|
|
39
|
+
# no-op that returns false.
|
|
40
|
+
#
|
|
41
|
+
# @param timeout [Float] Seconds to wait for an in-flight run before killing.
|
|
42
|
+
# @return [Boolean] true if this call removed the task, false if already gone.
|
|
43
|
+
def stop(timeout: 2.0)
|
|
44
|
+
Background.stop_task(self, timeout: timeout)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Boolean] true once stop has run (the task is no longer running).
|
|
48
|
+
def stopped?
|
|
49
|
+
!@running
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] true while the task is registered and ticking.
|
|
53
|
+
def running?
|
|
54
|
+
@running
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Hash-style read, so `task[:thread]` / `task[:running]` still work.
|
|
58
|
+
def [](key)
|
|
59
|
+
case key
|
|
60
|
+
when :callback then @callback
|
|
61
|
+
when :interval then @interval
|
|
62
|
+
when :thread then @thread
|
|
63
|
+
when :running then @running
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Hash-style write, so the scheduler's `task[:thread] = ...` still works.
|
|
68
|
+
def []=(key, value)
|
|
69
|
+
case key
|
|
70
|
+
when :callback then @callback = value
|
|
71
|
+
when :interval then @interval = value
|
|
72
|
+
when :thread then @thread = value
|
|
73
|
+
when :running then @running = value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
15
78
|
class << self
|
|
16
79
|
# Register a periodic callback.
|
|
17
80
|
#
|
|
18
81
|
# @param callback [#call, nil] Object responding to `call` with no args.
|
|
19
82
|
# @param interval [Float] Seconds between invocations (default 1.0).
|
|
20
83
|
# @param block [Proc] Optional block (used if callback is nil).
|
|
21
|
-
# @return [
|
|
84
|
+
# @return [Task] The registered task handle — call `.stop` to end it.
|
|
22
85
|
def register(callback = nil, interval: 1.0, &block)
|
|
23
86
|
cb = callback || block
|
|
24
87
|
raise ArgumentError, "background requires a callback or block" if cb.nil?
|
|
25
88
|
raise ArgumentError, "callback must respond to :call" unless cb.respond_to?(:call)
|
|
26
89
|
|
|
27
|
-
task =
|
|
90
|
+
task = Task.new(cb, interval.to_f)
|
|
28
91
|
mutex.synchronize { tasks << task }
|
|
29
92
|
start_task(task)
|
|
30
93
|
task
|
|
31
94
|
end
|
|
32
95
|
|
|
33
|
-
# All registered task
|
|
96
|
+
# All registered task handles. Tests use this for introspection.
|
|
34
97
|
def tasks
|
|
35
98
|
@tasks ||= []
|
|
36
99
|
end
|
|
37
100
|
|
|
101
|
+
# Number of REGISTERED background tasks (stopped ones are already gone).
|
|
102
|
+
# The count half of the ONE shared surface, matching Python's
|
|
103
|
+
# `background_task_count()`, PHP's `backgroundTaskCount()` and Node's
|
|
104
|
+
# `backgroundTaskCount()`.
|
|
105
|
+
#
|
|
106
|
+
# @return [Integer]
|
|
107
|
+
def count
|
|
108
|
+
mutex.synchronize { tasks.length }
|
|
109
|
+
end
|
|
110
|
+
|
|
38
111
|
# Stop and join every running task. Called on graceful shutdown.
|
|
39
112
|
#
|
|
40
|
-
# Each stop_task deregisters its own
|
|
113
|
+
# Each stop_task deregisters its own handle, so there is no blanket
|
|
41
114
|
# `tasks.clear` here: clearing would ALSO drop a task registered while
|
|
42
115
|
# this loop was running — leaving its thread alive but invisible in the
|
|
43
116
|
# registry, which is the worse of the two failure modes.
|
|
@@ -46,29 +119,37 @@ module Tina4
|
|
|
46
119
|
snapshot.each { |task| stop_task(task, timeout: timeout) }
|
|
47
120
|
end
|
|
48
121
|
|
|
49
|
-
# Stop a single task and DEREGISTER it. Used by
|
|
50
|
-
#
|
|
122
|
+
# Stop a single task and DEREGISTER it. Used by `Task#stop`, by graceful
|
|
123
|
+
# shutdown, and by any subsystem that owns a task for part of its life
|
|
51
124
|
# (e.g. Mqtt::Client#stop_keepalive).
|
|
52
125
|
#
|
|
53
|
-
# The
|
|
54
|
-
#
|
|
55
|
-
# the
|
|
56
|
-
#
|
|
126
|
+
# The handle is removed from `tasks` so the registry never reports a stopped
|
|
127
|
+
# task as registered — leaving it in place made `tasks` grow for the life of
|
|
128
|
+
# the process on every start/stop cycle and made introspection lie about
|
|
129
|
+
# what is actually running.
|
|
130
|
+
#
|
|
131
|
+
# Idempotent: a second call on the same handle removes nothing, finds no
|
|
132
|
+
# thread and returns false.
|
|
57
133
|
#
|
|
58
|
-
#
|
|
59
|
-
# no thread and returns safely.
|
|
134
|
+
# @return [Boolean] true if this call removed a registered task, else false.
|
|
60
135
|
def stop_task(task, timeout: 2.0)
|
|
61
|
-
task
|
|
62
|
-
# Identity, not equality: `tasks.delete(task)` uses `==`, which would
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
mutex.synchronize
|
|
136
|
+
task.running = false
|
|
137
|
+
# Identity, not equality: `tasks.delete(task)` uses `==`, which would take
|
|
138
|
+
# out any OTHER handle that happens to compare equal. Only this exact one
|
|
139
|
+
# goes, and we record whether it WAS registered so stop() is a truthful bool.
|
|
140
|
+
was_registered = mutex.synchronize do
|
|
141
|
+
present = tasks.any? { |registered| registered.equal?(task) }
|
|
142
|
+
tasks.delete_if { |registered| registered.equal?(task) }
|
|
143
|
+
present
|
|
144
|
+
end
|
|
66
145
|
|
|
67
|
-
thread = task
|
|
68
|
-
|
|
146
|
+
thread = task.thread
|
|
147
|
+
if thread
|
|
148
|
+
thread.join(timeout) || thread.kill
|
|
149
|
+
task.thread = nil
|
|
150
|
+
end
|
|
69
151
|
|
|
70
|
-
|
|
71
|
-
task[:thread] = nil
|
|
152
|
+
was_registered
|
|
72
153
|
end
|
|
73
154
|
|
|
74
155
|
private
|
|
@@ -78,14 +159,14 @@ module Tina4
|
|
|
78
159
|
end
|
|
79
160
|
|
|
80
161
|
def start_task(task)
|
|
81
|
-
task
|
|
82
|
-
task
|
|
83
|
-
while task
|
|
84
|
-
sleep task
|
|
85
|
-
break unless task
|
|
162
|
+
task.running = true
|
|
163
|
+
task.thread = Thread.new do
|
|
164
|
+
while task.running
|
|
165
|
+
sleep task.interval
|
|
166
|
+
break unless task.running
|
|
86
167
|
|
|
87
168
|
begin
|
|
88
|
-
task
|
|
169
|
+
task.callback.call
|
|
89
170
|
rescue => e
|
|
90
171
|
# Never let a callback error kill the thread — next interval still fires.
|
|
91
172
|
if defined?(Tina4::Log) && Tina4::Log.respond_to?(:error)
|
data/lib/tina4/cache.rb
CHANGED
|
@@ -140,8 +140,9 @@ module Tina4
|
|
|
140
140
|
@mutex.synchronize { @store.size }
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
-
# Generate a stable cache key from a SQL query and params.
|
|
144
|
-
#
|
|
143
|
+
# Generate a stable cache key from a SQL query and params. This is the ONE
|
|
144
|
+
# query-key source used by the ORM cache path (the former duplicate on
|
|
145
|
+
# SQLTranslator was removed - SQLTRANS-DEC-02).
|
|
145
146
|
#
|
|
146
147
|
# @param sql [String]
|
|
147
148
|
# @param params [Array, nil]
|