gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitd8b12d67d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmred8b12d67/shortids.hl

7.5 KB

  1. // shortids.hl — SHORT IDS (tracker.worldapi.org#27, mission 060): every movie, series and person has its own 5-character id
  2. // (`shortId`, like `a9s9a`), shown below its poster/photo; `/<shortId>` answers 301 to its page. Creator: "i wanted to give all
  3. // our movies, shows, series and persons own short ids like a9s9a … those ids show below the poster."
  4. //
  5. // OLD IDS: the old tracker had them on 702 of 9,453 shows (Show.jsonl field `id`, base62 counter '100jh'…'1012y', all of
  6. // them [a-z0-9]{5}). `data/old-short-ids.json` (made once by tools/old-short-ids.hl) maps the old Mongo `_id` (= our
  7. // `oldId`, tools/migrate.hl) → that id; such a show gets it back. Persons never had one.
  8. // NEW IDS: 5 random characters [a-z0-9] (36^5 = 60 M) — never one that is taken or one of the old ids (all 702 are
  9. // reserved, even where the show is gone).
  10. // UNIQUE ACROSS SHOWS AND PERSONS: one in-memory map `owners` (short id → { kind, id }) over BOTH tables — an mpackdb index
  11. // is per table and cannot span two. Built at start by `ensureShortIds` (one scan of each table); every id handed
  12. // out goes through it. A record whose short id another record already owns (never seen; logged) gets a new one.
  13. // NEW RECORDS get one when they are created: `claimShortId(kind, id, oldId)` in search.hl importTitle and people.hl
  14. // addMinimalTitle (the only places the app creates shows; persons are only created by tools/migrate.hl).
  15. // THE BACKFILL: the records without one (at first every row: 9,453 shows + 15,157 persons) are given theirs in the
  16. // background, a time-boxed batch per clock tick (project.hl `shortIdTick` → `shortIdStep`), so pages are served
  17. // between two batches; after a restart it goes on with what is still missing. Real data: STATUS.md mission 060.
  18. //
  19. // ONE PROCESS PER STORAGE (hybriel#21): the map lives in the app's process; the tables are shows.hl's.
  20. import { now } from 'hl:time'
  21. import { env } from 'hl:proc'
  22. import { readFile, exists } from 'hl:fs'
  23. import { randomBytes } from 'hl:crypto'
  24. import { showsTable, personsTable } from './shows.hl'
  25. static shortIdChars = 'abcdefghijklmnopqrstuvwxyz0123456789'
  26. static hexDigits = '0123456789abcdef'
  27. // short id → { kind ('show' | 'person'), id }
  28. static owners = {}
  29. // the old tracker's ids: oldId → short id, and every one of them → true (reserved)
  30. static oldShortIds = {}
  31. static reserved = {}
  32. // the records still without a short id, [{ kind, id }], worked off from `state.queuePos`. Filled in place (push) and never
  33. // reassigned (a static cannot be) — kept OUT of `state`: reading `state.queue[i]` copied the whole 25k list on every access
  34. // (measured: ~3 ms per record instead of ~0.2)
  35. static queue = []
  36. static state = { built = false queuePos = 0 oldLoaded = 0 scanned = 0 existing = 0 duplicates = 0 ms = 0 assigned = 0 oldKept = 0 }
  37. // the file of old ids: TRACKER_OLD_SHORT_IDS, else data/old-short-ids.json beside project.hl
  38. static oldFile = () => {
  39. let f = env('TRACKER_OLD_SHORT_IDS')
  40. return f != null && f.trim() != '' ? f.trim() : './data/old-short-ids.json'
  41. }
  42. // is this a short id at all (5 characters, a–z, 0–9)?
  43. static isShortId = (s) => {
  44. if (s == null || hlTypeName(s) != 'String' || s.length != 5) { return false }
  45. let i = 0
  46. while (i < 5) {
  47. if (!shortIdChars.includes(s[i])) { return false }
  48. i = i + 1
  49. }
  50. return true
  51. }
  52. // 5 random characters a–z0–9, not taken and not an old one. Bytes ≥ 252 are skipped (252 = 7 × 36), so every character
  53. // is equally likely.
  54. static randomShortId = () => {
  55. while (true) {
  56. let hex = randomBytes(16, 'hex')
  57. let out = ''
  58. let i = 0
  59. while (i < hex.length && out.length < 5) {
  60. let b = hexDigits.indexOf(hex[i]) * 16 + hexDigits.indexOf(hex[i + 1])
  61. if (b < 252) { out = out + shortIdChars[b % 36] }
  62. i = i + 2
  63. }
  64. if (out.length == 5 && owners[out] == null && reserved[out] != true) { return out }
  65. }
  66. }
  67. // the short id for a record that has none: its old one when the old tracker had one (and nobody holds it), else a new
  68. // one — taken in `owners` at once
  69. static claimShortId = (kind, id, oldId) => {
  70. ensureShortIds()
  71. let sid = oldId != null ? oldShortIds['' + oldId] : null
  72. if (sid != null && owners[sid] == null) { state.oldKept = state.oldKept + 1 } else { sid = randomShortId() }
  73. owners[sid] = { kind = kind id = id }
  74. return sid
  75. }
  76. // does this record own its short id? (null / malformed / held by another record → no)
  77. static ownsShortId = (kind, r) => {
  78. if (!isShortId(r.shortId)) { return false }
  79. let o = owners[r.shortId]
  80. return o != null && o.kind == kind && o.id == r.id
  81. }
  82. // one table's rows into `owners`; the rows without a (valid, own) short id into the queue
  83. static scanTable = (kind, table) => {
  84. for (r of table.find(null, null)) {
  85. state.scanned = state.scanned + 1
  86. if (isShortId(r.shortId) && owners[r.shortId] == null) {
  87. owners[r.shortId] = { kind = kind id = r.id }
  88. state.existing = state.existing + 1
  89. } else {
  90. if (r.shortId != null) {
  91. state.duplicates = state.duplicates + 1
  92. console.log('short ids: ' + kind + ' ' + r.id + ' has "' + r.shortId + '", ' + (isShortId(r.shortId) ? 'taken by ' + owners[r.shortId].kind + ' ' + owners[r.shortId].id : 'not a short id') + ' — gets a new one')
  93. }
  94. queue.push({ kind = kind id = r.id })
  95. }
  96. }
  97. return true
  98. }
  99. // at start (project.hl): the old ids, the map of every short id in use, the queue of records without one
  100. static ensureShortIds = () => {
  101. if (state.built) { return true }
  102. state.built = true
  103. let t0 = now()
  104. let f = oldFile()
  105. if (exists(f)) {
  106. let m = JSON.parse(readFile(f))
  107. let n = 0
  108. for (k of m.keys()) {
  109. let sid = m[k]
  110. if (isShortId(sid)) { oldShortIds[k] = sid reserved[sid] = true n = n + 1 }
  111. }
  112. state.oldLoaded = n
  113. } else { console.log('short ids: no old ids (' + f + ' missing)') }
  114. scanTable('show', showsTable)
  115. scanTable('person', personsTable)
  116. state.ms = now() - t0
  117. console.log('short ids: ' + state.scanned + ' records, ' + state.existing + ' with a short id, ' + queue.length + ' without, ' + state.oldLoaded + ' old ids, ' + state.ms + ' ms')
  118. return true
  119. }
  120. static tableOf = (kind) => { return kind == 'person' ? personsTable : showsTable }
  121. // the backfill, one batch: records from the queue until `budgetMs` is used up → { done, assigned, left }
  122. static shortIdStep = (budgetMs) => {
  123. ensureShortIds()
  124. let t0 = now()
  125. let n = 0
  126. while (state.queuePos < queue.length && now() - t0 < budgetMs) {
  127. let item = queue[state.queuePos]
  128. state.queuePos = state.queuePos + 1
  129. let table = tableOf(item.kind)
  130. let r = table.fetch(item.id)
  131. if (r != null && !ownsShortId(item.kind, r)) {
  132. let x = r
  133. x.shortId = claimShortId(item.kind, x.id, x.oldId)
  134. if (table.update(x.id, x) == null) { console.log('short ids: ' + item.kind + ' ' + x.id + ' not stored: ' + table.lastError()) } else { n = n + 1 }
  135. }
  136. }
  137. state.assigned = state.assigned + n
  138. let done = state.queuePos >= queue.length
  139. return { done = done assigned = n left = queue.length - state.queuePos }
  140. }
  141. static shortIdStats = () => { return state }
  142. static persistShortIds = () => {
  143. showsTable.persist()
  144. personsTable.persist()
  145. return true
  146. }
  147. static shortIdsLeft = () => { return queue.length - state.queuePos }
  148. // `/<shortId>` (project.hl): the page's path, or null
  149. static shortIdPath = (sid) => {
  150. if (!isShortId(sid)) { return null }
  151. ensureShortIds()
  152. let o = owners[sid]
  153. if (o == null) { return null }
  154. let r = tableOf(o.kind).fetch(o.id)
  155. if (r == null || r.urlSegment == null) { return null }
  156. return (o.kind == 'person' ? '/person/' : '/shows/') + r.urlSegment
  157. }

Branches

Latest commits

  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
  • f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
  • 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
  • f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
  • f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
  • 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
  • 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
  • c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
  • 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
  • 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
  • 47a3cae6STATUS: mission 053 merge commit idsmre
  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre
  • cbdc4ea7tracker#12: link icons TMDB/IMDb/TVDB/TVmaze; sync fills missing ids (TVmaze lookup); movies fetched via /movie/mre
  • b105bcd8tracker#11: Hybriel master ff51cf46 (checks no longer vanish), mobile-first styles, carets, follow button, sign-in modal, inverted check, orange castmre