postgres.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. // @ts-check
  2. const { Chunk } = require('overleaf-editor-core')
  3. const assert = require('../assert')
  4. const knex = require('../knex')
  5. const knexReadOnly = require('../knex_read_only')
  6. const { ChunkVersionConflictError } = require('./errors')
  7. const {
  8. updateProjectRecord,
  9. lookupMongoProjectIdFromHistoryId,
  10. } = require('./mongo')
  11. const DUPLICATE_KEY_ERROR_CODE = '23505'
  12. /**
  13. * @import { Knex } from 'knex'
  14. */
  15. /**
  16. * Get the latest chunk's metadata from the database
  17. * @param {string} projectId
  18. * @param {Object} [opts]
  19. * @param {boolean} [opts.readOnly]
  20. */
  21. async function getLatestChunk(projectId, opts = {}) {
  22. assert.postgresId(projectId, 'bad projectId')
  23. const { readOnly = false } = opts
  24. const record = await (readOnly ? knexReadOnly : knex)('chunks')
  25. .where('doc_id', parseInt(projectId, 10))
  26. .orderBy('end_version', 'desc')
  27. .first()
  28. if (record == null) {
  29. return null
  30. }
  31. return chunkFromRecord(record)
  32. }
  33. /**
  34. * Get the metadata for the chunk that contains the given version.
  35. *
  36. * @param {string} projectId
  37. * @param {number} version
  38. * @param {object} [opts]
  39. * @param {boolean} [opts.preferNewer] - If the version is at the boundary of
  40. * two chunks, return the newer chunk.
  41. */
  42. async function getChunkForVersion(projectId, version, opts = {}) {
  43. assert.postgresId(projectId, 'bad projectId')
  44. const record = await knex('chunks')
  45. .where('doc_id', parseInt(projectId, 10))
  46. .where('start_version', '<=', version)
  47. .where('end_version', '>=', version)
  48. .orderBy('end_version', opts.preferNewer ? 'desc' : 'asc')
  49. .first()
  50. if (!record) {
  51. throw new Chunk.VersionNotFoundError(projectId, version)
  52. }
  53. return chunkFromRecord(record)
  54. }
  55. /**
  56. * Get the metadata for the chunk that contains the given version.
  57. *
  58. * @param {string} projectId
  59. * @param {Date} timestamp
  60. */
  61. async function getFirstChunkBeforeTimestamp(projectId, timestamp) {
  62. assert.date(timestamp, 'bad timestamp')
  63. const recordActive = await getChunkForVersion(projectId, 0)
  64. // projectId must be valid if getChunkForVersion did not throw
  65. if (recordActive && recordActive.endTimestamp <= timestamp) {
  66. return recordActive
  67. }
  68. // fallback to deleted chunk
  69. const recordDeleted = await knex('old_chunks')
  70. .where('doc_id', parseInt(projectId, 10))
  71. .where('start_version', '=', 0)
  72. .where('end_timestamp', '<=', timestamp)
  73. .orderBy('end_version', 'desc')
  74. .first()
  75. if (recordDeleted) {
  76. return chunkFromRecord(recordDeleted)
  77. }
  78. throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
  79. }
  80. /**
  81. * Get the metadata for the chunk that contains the version that was current at
  82. * the given timestamp.
  83. *
  84. * @param {string} projectId
  85. * @param {Date} timestamp
  86. */
  87. async function getLastActiveChunkBeforeTimestamp(projectId, timestamp) {
  88. assert.date(timestamp, 'bad timestamp')
  89. assert.postgresId(projectId, 'bad projectId')
  90. const query = knex('chunks')
  91. .where('doc_id', parseInt(projectId, 10))
  92. .where(function () {
  93. this.where('end_timestamp', '<=', timestamp).orWhere(
  94. 'end_timestamp',
  95. null
  96. )
  97. })
  98. .orderBy('end_version', 'desc', 'last')
  99. const record = await query.first()
  100. if (!record) {
  101. throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
  102. }
  103. return chunkFromRecord(record)
  104. }
  105. /**
  106. * Get the metadata for the chunk that contains the version that was current at
  107. * the given timestamp.
  108. *
  109. * @param {string} projectId
  110. * @param {Date} timestamp
  111. */
  112. async function getChunkForTimestamp(projectId, timestamp) {
  113. assert.postgresId(projectId, 'bad projectId')
  114. // This query will find the latest chunk after the timestamp (query orders
  115. // in reverse chronological order), OR the latest chunk
  116. // This accounts for the case where the timestamp is ahead of the chunk's
  117. // timestamp and therefore will not return any results
  118. const whereAfterEndTimestampOrLatestChunk = knex.raw(
  119. 'end_timestamp >= ? ' +
  120. 'OR id = ( ' +
  121. 'SELECT id FROM chunks ' +
  122. 'WHERE doc_id = ? ' +
  123. 'ORDER BY end_version desc LIMIT 1' +
  124. ')',
  125. [timestamp, parseInt(projectId, 10)]
  126. )
  127. const record = await knex('chunks')
  128. .where('doc_id', parseInt(projectId, 10))
  129. .where(whereAfterEndTimestampOrLatestChunk)
  130. .orderBy('end_version')
  131. .first()
  132. if (!record) {
  133. throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
  134. }
  135. return chunkFromRecord(record)
  136. }
  137. /**
  138. * Build a chunk metadata object from the database record
  139. */
  140. function chunkFromRecord(record) {
  141. return {
  142. id: record.id.toString(),
  143. startVersion: record.start_version,
  144. endVersion: record.end_version,
  145. endTimestamp: record.end_timestamp,
  146. }
  147. }
  148. /**
  149. * Get all of a project's chunk ids
  150. *
  151. * @param {string} projectId
  152. */
  153. async function getProjectChunkIds(projectId) {
  154. assert.postgresId(projectId, 'bad projectId')
  155. const records = await knex('chunks')
  156. .select('id')
  157. .where('doc_id', parseInt(projectId, 10))
  158. return records.map(record => record.id)
  159. }
  160. /**
  161. * Get all of a projects chunks directly
  162. *
  163. * @param {string} projectId
  164. */
  165. async function getProjectChunks(projectId) {
  166. assert.postgresId(projectId, 'bad projectId')
  167. const records = await knex('chunks')
  168. .select()
  169. .where('doc_id', parseInt(projectId, 10))
  170. .orderBy('end_version')
  171. return records.map(chunkFromRecord)
  172. }
  173. /**
  174. * Insert a pending chunk before sending it to object storage.
  175. *
  176. * @param {string} projectId
  177. * @param {Chunk} chunk
  178. */
  179. async function insertPendingChunk(projectId, chunk) {
  180. assert.postgresId(projectId, 'bad projectId')
  181. const result = await knex.first(
  182. knex.raw("nextval('chunks_id_seq'::regclass)::integer as chunkid")
  183. )
  184. const chunkId = result.chunkid
  185. await knex('pending_chunks').insert({
  186. id: chunkId,
  187. doc_id: parseInt(projectId, 10),
  188. end_version: chunk.getEndVersion(),
  189. start_version: chunk.getStartVersion(),
  190. end_timestamp: chunk.getEndTimestamp(),
  191. })
  192. return chunkId.toString()
  193. }
  194. /**
  195. * Record that a new chunk was created.
  196. *
  197. * @param {string} projectId
  198. * @param {Chunk} chunk
  199. * @param {string} chunkId
  200. * @param {object} opts
  201. * @param {Date} [opts.earliestChangeTimestamp]
  202. * @param {string} [opts.oldChunkId]
  203. */
  204. async function confirmCreate(projectId, chunk, chunkId, opts = {}) {
  205. assert.postgresId(projectId, 'bad projectId')
  206. await knex.transaction(async tx => {
  207. if (opts.oldChunkId != null) {
  208. await _assertChunkIsNotClosed(tx, projectId, opts.oldChunkId)
  209. await _closeChunk(tx, projectId, opts.oldChunkId)
  210. }
  211. await Promise.all([
  212. _deletePendingChunk(tx, projectId, chunkId),
  213. _insertChunk(tx, projectId, chunk, chunkId),
  214. ])
  215. await updateProjectRecord(
  216. // The history id in Mongo is an integer for Postgres projects
  217. parseInt(projectId, 10),
  218. chunk,
  219. opts.earliestChangeTimestamp
  220. )
  221. })
  222. }
  223. /**
  224. * Record that a chunk was replaced by a new one.
  225. *
  226. * @param {string} projectId
  227. * @param {string} oldChunkId
  228. * @param {Chunk} newChunk
  229. * @param {string} newChunkId
  230. */
  231. async function confirmUpdate(
  232. projectId,
  233. oldChunkId,
  234. newChunk,
  235. newChunkId,
  236. opts = {}
  237. ) {
  238. assert.postgresId(projectId, 'bad projectId')
  239. await knex.transaction(async tx => {
  240. await _assertChunkIsNotClosed(tx, projectId, oldChunkId)
  241. await _deleteChunks(tx, { doc_id: projectId, id: oldChunkId })
  242. await Promise.all([
  243. _deletePendingChunk(tx, projectId, newChunkId),
  244. _insertChunk(tx, projectId, newChunk, newChunkId),
  245. ])
  246. await updateProjectRecord(
  247. // The history id in Mongo is an integer for Postgres projects
  248. parseInt(projectId, 10),
  249. newChunk,
  250. opts.earliestChangeTimestamp
  251. )
  252. })
  253. }
  254. /**
  255. * Delete a pending chunk
  256. *
  257. * @param {Knex} tx
  258. * @param {string} projectId
  259. * @param {string} chunkId
  260. */
  261. async function _deletePendingChunk(tx, projectId, chunkId) {
  262. await tx('pending_chunks')
  263. .where({
  264. doc_id: parseInt(projectId, 10),
  265. id: parseInt(chunkId, 10),
  266. })
  267. .del()
  268. }
  269. /**
  270. * Adds an active chunk
  271. *
  272. * @param {Knex} tx
  273. * @param {string} projectId
  274. * @param {Chunk} chunk
  275. * @param {string} chunkId
  276. */
  277. async function _insertChunk(tx, projectId, chunk, chunkId) {
  278. const startVersion = chunk.getStartVersion()
  279. const endVersion = chunk.getEndVersion()
  280. try {
  281. await tx('chunks').insert({
  282. id: parseInt(chunkId, 10),
  283. doc_id: parseInt(projectId, 10),
  284. start_version: startVersion,
  285. end_version: endVersion,
  286. end_timestamp: chunk.getEndTimestamp(),
  287. })
  288. } catch (err) {
  289. if (
  290. err instanceof Error &&
  291. 'code' in err &&
  292. err.code === DUPLICATE_KEY_ERROR_CODE
  293. ) {
  294. throw new ChunkVersionConflictError(
  295. 'chunk start or end version is not unique',
  296. { projectId, chunkId, startVersion, endVersion }
  297. )
  298. }
  299. throw err
  300. }
  301. }
  302. /**
  303. * Check that a chunk is not closed
  304. *
  305. * This is used to synchronize chunk creations and extensions.
  306. *
  307. * @param {Knex} tx
  308. * @param {string} projectId
  309. * @param {string} chunkId
  310. */
  311. async function _assertChunkIsNotClosed(tx, projectId, chunkId) {
  312. const record = await tx('chunks')
  313. .forUpdate()
  314. .select('closed')
  315. .where('doc_id', parseInt(projectId, 10))
  316. .where('id', parseInt(chunkId, 10))
  317. .first()
  318. if (!record) {
  319. throw new ChunkVersionConflictError('unable to close chunk: not found', {
  320. projectId,
  321. chunkId,
  322. })
  323. }
  324. if (record.closed) {
  325. throw new ChunkVersionConflictError(
  326. 'unable to close chunk: already closed',
  327. {
  328. projectId,
  329. chunkId,
  330. }
  331. )
  332. }
  333. }
  334. /**
  335. * Close a chunk
  336. *
  337. * A closed chunk can no longer be extended.
  338. *
  339. * @param {Knex} tx
  340. * @param {string} projectId
  341. * @param {string} chunkId
  342. */
  343. async function _closeChunk(tx, projectId, chunkId) {
  344. await tx('chunks')
  345. .update({ closed: true })
  346. .where('doc_id', parseInt(projectId, 10))
  347. .where('id', parseInt(chunkId, 10))
  348. }
  349. /**
  350. * Delete a chunk.
  351. *
  352. * @param {string} projectId
  353. * @param {string} chunkId
  354. */
  355. async function deleteChunk(projectId, chunkId) {
  356. assert.postgresId(projectId, 'bad projectId')
  357. assert.integer(chunkId, 'bad chunkId')
  358. await _deleteChunks(knex, {
  359. doc_id: parseInt(projectId, 10),
  360. id: parseInt(chunkId, 10),
  361. })
  362. }
  363. /**
  364. * Delete all of a project's chunks
  365. *
  366. * @param {string} projectId
  367. */
  368. async function deleteProjectChunks(projectId) {
  369. assert.postgresId(projectId, 'bad projectId')
  370. await knex.transaction(async tx => {
  371. await _deleteChunks(knex, { doc_id: parseInt(projectId, 10) })
  372. })
  373. }
  374. /**
  375. * Delete many chunks
  376. *
  377. * @param {Knex} tx
  378. * @param {any} whereClause
  379. */
  380. async function _deleteChunks(tx, whereClause) {
  381. const rows = await tx('chunks').where(whereClause).del().returning('*')
  382. if (rows.length === 0) {
  383. return
  384. }
  385. const oldChunks = rows.map(row => ({
  386. doc_id: row.doc_id,
  387. chunk_id: row.id,
  388. start_version: row.start_version,
  389. end_version: row.end_version,
  390. end_timestamp: row.end_timestamp,
  391. deleted_at: tx.fn.now(),
  392. }))
  393. await tx('old_chunks').insert(oldChunks)
  394. }
  395. /**
  396. * Get a batch of old chunks for deletion
  397. *
  398. * @param {number} count
  399. * @param {number} minAgeSecs
  400. */
  401. async function getOldChunksBatch(count, minAgeSecs) {
  402. const maxDeletedAt = new Date(Date.now() - minAgeSecs * 1000)
  403. const records = await knex('old_chunks')
  404. .whereNull('deleted_at')
  405. .orWhere('deleted_at', '<', maxDeletedAt)
  406. .orderBy('chunk_id')
  407. .limit(count)
  408. return records.map(oldChunk => ({
  409. projectId: oldChunk.doc_id.toString(),
  410. chunkId: oldChunk.chunk_id.toString(),
  411. }))
  412. }
  413. /**
  414. * Delete a batch of old chunks from the database
  415. *
  416. * @param {string[]} chunkIds
  417. */
  418. async function deleteOldChunks(chunkIds) {
  419. await knex('old_chunks')
  420. .whereIn(
  421. 'chunk_id',
  422. chunkIds.map(id => parseInt(id, 10))
  423. )
  424. .del()
  425. }
  426. /**
  427. * Generate a new project id
  428. */
  429. async function generateProjectId() {
  430. const record = await knex.first(
  431. knex.raw("nextval('docs_id_seq'::regclass)::integer as doc_id")
  432. )
  433. return record.doc_id.toString()
  434. }
  435. async function resolveHistoryIdToMongoProjectId(projectId) {
  436. return await lookupMongoProjectIdFromHistoryId(parseInt(projectId, 10))
  437. }
  438. module.exports = {
  439. getLatestChunk,
  440. getFirstChunkBeforeTimestamp,
  441. getLastActiveChunkBeforeTimestamp,
  442. getChunkForVersion,
  443. getChunkForTimestamp,
  444. getProjectChunkIds,
  445. getProjectChunks,
  446. insertPendingChunk,
  447. confirmCreate,
  448. confirmUpdate,
  449. deleteChunk,
  450. deleteProjectChunks,
  451. getOldChunksBatch,
  452. deleteOldChunks,
  453. generateProjectId,
  454. resolveHistoryIdToMongoProjectId,
  455. }