data-ark 0.1.1 → 0.1.3

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.
package/src/state.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
2
2
  import { promises as fs } from 'node:fs'
3
3
  import path from 'node:path'
4
4
 
5
- import { defaultConfigDir } from './config.js'
5
+ import { defaultConfigDir, writeJsonAtomic } from './config.js'
6
6
 
7
7
  export function stateDir(configDir = defaultConfigDir()) {
8
8
  return path.join(configDir, 'state')
@@ -12,7 +12,7 @@ export function stateKey(absPath, size, mtimeMs) {
12
12
  return createHash('sha1').update(`${absPath}:${size}:${mtimeMs}`).digest('hex')
13
13
  }
14
14
 
15
- function stateFile(key, configDir) {
15
+ export function stateFile(key, configDir = defaultConfigDir()) {
16
16
  return path.join(stateDir(configDir), `${key}.json`)
17
17
  }
18
18
 
@@ -27,14 +27,7 @@ export async function loadState(key, configDir = defaultConfigDir()) {
27
27
  }
28
28
 
29
29
  export async function saveState(key, state, configDir = defaultConfigDir()) {
30
- const dir = stateDir(configDir)
31
- await fs.mkdir(dir, { recursive: true, mode: 0o700 })
32
-
33
- const file = stateFile(key, configDir)
34
- const tmp = `${file}.tmp`
35
-
36
- await fs.writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
37
- await fs.rename(tmp, file)
30
+ await writeJsonAtomic(stateFile(key, configDir), state)
38
31
  }
39
32
 
40
33
  export async function markChunkDone(key, state, i, entry, configDir = defaultConfigDir()) {
@@ -50,3 +43,76 @@ export async function clearState(key, configDir = defaultConfigDir()) {
50
43
  if (err.code !== 'ENOENT') throw err
51
44
  }
52
45
  }
46
+
47
+ // A state file is only useful while its backup can still be resumed, and nothing ever
48
+ // removes one whose file was edited since: the key includes mtime, so that state can never
49
+ // match again. Left alone the directory only grows, and with it the report `status` prints.
50
+ export const MAX_STATES = 20
51
+
52
+ // The newest states are the ones worth keeping, and a state file is rewritten every time a
53
+ // chunk lands, so its mtime is when this backup last made progress. Returns the states that
54
+ // were dropped: the caller says their ids out loud, because after this the id is the only
55
+ // way left to find those chunks in the chat.
56
+ export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STATES) {
57
+ let names
58
+ try {
59
+ names = await fs.readdir(stateDir(configDir))
60
+ } catch (err) {
61
+ if (err.code === 'ENOENT') return []
62
+ throw err
63
+ }
64
+
65
+ const files = []
66
+
67
+ for (const name of names) {
68
+ if (!name.endsWith('.json')) continue
69
+
70
+ const file = path.join(stateDir(configDir), name)
71
+
72
+ try {
73
+ const stat = await fs.stat(file)
74
+ files.push({ key: name.slice(0, -'.json'.length), file, mtimeMs: stat.mtimeMs })
75
+ } catch (err) {
76
+ // Gone between readdir and stat: nothing left to prune.
77
+ if (err.code !== 'ENOENT') throw err
78
+ }
79
+ }
80
+
81
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs)
82
+
83
+ const dropped = []
84
+
85
+ for (const { key, file } of files.slice(keep)) {
86
+ // Read before unlink: a file that cannot be read back, or that carries no id, is
87
+ // still pruned — it just cannot be named, and a report naming nothing helps no one.
88
+ const state = await loadState(key, configDir)
89
+ await fs.unlink(file)
90
+ if (state?.id) dropped.push(state)
91
+ }
92
+
93
+ return dropped
94
+ }
95
+
96
+ // status needs every unfinished backup at once. A state file that cannot be read is skipped
97
+ // rather than fatal, for the same reason loadState returns null: one corrupt file must not
98
+ // hide the other backups still waiting to be finished.
99
+ export async function listStates(configDir = defaultConfigDir()) {
100
+ let names
101
+ try {
102
+ names = await fs.readdir(stateDir(configDir))
103
+ } catch (err) {
104
+ if (err.code === 'ENOENT') return []
105
+ throw err
106
+ }
107
+
108
+ const states = []
109
+
110
+ for (const name of names) {
111
+ if (!name.endsWith('.json')) continue
112
+
113
+ const state = await loadState(name.slice(0, -'.json'.length), configDir)
114
+ if (state) states.push(state)
115
+ }
116
+
117
+ return states
118
+ }
package/src/uploader.js CHANGED
@@ -16,7 +16,10 @@ const read = promisify(readCallback)
16
16
  export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
17
17
 
18
18
  async function readExactly(fd, length, position) {
19
- const buffer = Buffer.alloc(length)
19
+ // allocUnsafe skips zero-filling 512KB per part — about 0.4s of memset per 1800MB chunk,
20
+ // on the same thread that drives the in-flight requests. Safe only because the loop below
21
+ // either fills every byte or throws: no uninitialised byte can reach a request or the hash.
22
+ const buffer = Buffer.allocUnsafe(length)
20
23
  let filled = 0
21
24
 
22
25
  while (filled < length) {