@dforge-core/dforge-mcp 0.2.45 → 0.2.47
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/CHANGELOG.md +45 -0
- package/README.md +2 -1
- package/dist/server.js +71 -3
- package/package.json +3 -3
- package/resources/docs/dsl-reference.md +1 -1
- package/resources/schemas/data-views.schema.json +50 -0
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +375 -0
- package/skills/dforge-1c-import/references/environment.md +98 -0
- package/skills/dforge-1c-import/references/loading.md +164 -0
- package/skills/dforge-1c-import/references/measurements.md +210 -0
- package/skills/dforge-1c-import/references/print-forms.md +148 -0
- package/skills/dforge-1c-import/scripts/1c.py +178 -0
- package/skills/dforge-1c-import/scripts/README.md +164 -0
- package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
- package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
- package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
- package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
- package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
- package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
- package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
- package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
- package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
- package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
- package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
- package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
- package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
- package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
- package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
- package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
- package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
- package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +68 -0
- package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
- package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
- package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
- package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
- package/skills/dforge-1c-import/scripts/lib/recorders.py +91 -0
- package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
- package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
- package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +493 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
- package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
- package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
- package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
- package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
- package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
- package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
- package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
- package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
- package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
- package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
- package/skills/dforge-mcp-author/SKILL.md +5 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
- package/skills/dforge-mcp-author/references/data-views.md +38 -2
- package/skills/dforge-mcp-author/references/formulas.md +32 -1
- package/skills/dforge-mcp-author/references/print-templates.md +30 -5
- package/skills/dforge-mcp-author/references/queries.md +17 -2
- package/skills/dforge-mcp-author/references/registers.md +1 -0
- package/skills/dforge-mcp-author/references/reports.md +1 -1
- package/skills/dforge-mcp-author/references/validation-checklist.md +1 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Bring a loaded tenant up to date with what 1C changed since the load: the cutover's delta.
|
|
5
|
+
|
|
6
|
+
python tools/1c.py delta [module ...] # dry run: what changed, per table, timed
|
|
7
|
+
python tools/1c.py delta [module ...] --apply # write it
|
|
8
|
+
python tools/1c.py delta --status # the watermarks
|
|
9
|
+
python tools/1c.py delta --baseline [module ...] # mark a tenant loaded before marks existed
|
|
10
|
+
python tools/1c.py delta --since 2026-09-17 # dry run as if loaded on that day
|
|
11
|
+
|
|
12
|
+
It reads the export, not 1C: bring it up to date with `dump1c data --changes` first (minutes,
|
|
13
|
+
where a full `dump1c data` is an hour and more), and the delta is what changed between
|
|
14
|
+
the rows the tenant was loaded from and the rows that export holds. Only an export of a
|
|
15
|
+
server infobase can carry one - a file infobase's `_Version` is a per-table counter, not a
|
|
16
|
+
database-wide rowversion, so a file export has no horizon to read changes against, and a
|
|
17
|
+
file infobase is small enough to reload whole (`migrate run --truncate`).
|
|
18
|
+
|
|
19
|
+
What 1C gives to detect change was measured, not assumed (docs/design-cutover.md section 3.1):
|
|
20
|
+
|
|
21
|
+
- `_Version` (rowversion) is on the OBJECT tables only - catalogs, documents, charts of
|
|
22
|
+
accounts and characteristic types. Tabular parts, register movements and information
|
|
23
|
+
registers have none. It is database-wide and monotonic, and none of the 472 tables
|
|
24
|
+
carrying it has an index on it - but a full scan of all of them for `_Version >= w` takes
|
|
25
|
+
half a second, so none is needed.
|
|
26
|
+
- A deletion leaves no row. It is found by key: every object key 1C holds (2.6 M) is read
|
|
27
|
+
once per run into a temporary table, and a target row whose key is not there was deleted.
|
|
28
|
+
- A tabular part changes only with its owner: 1C writes the object whole, so a changed line
|
|
29
|
+
bumps the owner's `_Version`. The lines of a changed owner are replaced.
|
|
30
|
+
- A register movement changes only with its recorder, the same way: posting, reposting and
|
|
31
|
+
unposting write the document. The movements of a changed recorder are replaced - read
|
|
32
|
+
through the recorder index (`_RecorderTRef, _RecorderRRef`) every register carries.
|
|
33
|
+
- What has neither - an independent information register, an aggregate the map reads
|
|
34
|
+
through a query - is read whole and compared row for row. They are small, bar two.
|
|
35
|
+
|
|
36
|
+
So each loaded entity is one of four kinds, `object`, `lines`, `movements` and `whole`,
|
|
37
|
+
decided from the column map and the schema dump, never named here.
|
|
38
|
+
|
|
39
|
+
Every row goes through `migrate`'s own planning and conversions (`plan_entity`, `converted`,
|
|
40
|
+
`reading`), into a staging table shaped like the target, and is compared there as text, so a
|
|
41
|
+
row 1C touched without changing reads as unchanged. The apply runs in replica mode, as the
|
|
42
|
+
load does, and ends the same way: `fill_missing` stands a placeholder in for a reference a
|
|
43
|
+
deletion left dangling, `seedstate` reseeds a balance whose journal moved, and the
|
|
44
|
+
`posted` markers are converged on the documents (that needs a token).
|
|
45
|
+
|
|
46
|
+
The watermark is kept in the tenant, in `df1c.watermark`, one row per loaded entity: the
|
|
47
|
+
`MIN_ACTIVE_ROWVERSION()` 1C reported before the export read the rows (`store.horizon()`). Below it every
|
|
48
|
+
transaction had committed, so the next delta reads `_Version >= mark` and misses nothing.
|
|
49
|
+
In the tenant rather than a file, because it is a fact about the tenant's rows - written in
|
|
50
|
+
the transaction that wrote them, so neither can survive without the other, and a second
|
|
51
|
+
tenant loaded from the same infobase keeps its own. `migrate run` writes it too.
|
|
52
|
+
|
|
53
|
+
A delta assumes the target holds only what 1C gave it: a row keyed by nothing 1C knows is
|
|
54
|
+
taken for a deletion. While 1C is the system of record, nothing is entered in the tenant.
|
|
55
|
+
"""
|
|
56
|
+
from __future__ import print_function
|
|
57
|
+
|
|
58
|
+
import argparse
|
|
59
|
+
import datetime
|
|
60
|
+
import re
|
|
61
|
+
import struct
|
|
62
|
+
import sys
|
|
63
|
+
import time
|
|
64
|
+
|
|
65
|
+
import migrate
|
|
66
|
+
import paths
|
|
67
|
+
import store
|
|
68
|
+
|
|
69
|
+
SCHEMA = "df1c"
|
|
70
|
+
KEYS_IN_LIST = 500
|
|
71
|
+
# Charts of calculation types (`_CKinds`) are objects too - `_IDRRef` and `_Version` like a
|
|
72
|
+
# catalog. Leaving them out read every row of the four the standard configuration loads as
|
|
73
|
+
# deleted, since their keys never reached the set of live ones.
|
|
74
|
+
OBJECT = re.compile(r"^_(Document|Reference|Acc|Chrc|CKinds|Node|BPr|Task)\d+$")
|
|
75
|
+
OWNER = re.compile(r"^(_(?:Document|Reference|Acc|Chrc|CKinds|Node)\d+)_IDRRef$")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------------------- watermarks
|
|
79
|
+
|
|
80
|
+
_READY = set()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ensure(pg):
|
|
84
|
+
if id(pg) in _READY:
|
|
85
|
+
return
|
|
86
|
+
cur = pg.cursor()
|
|
87
|
+
cur.execute("CREATE SCHEMA IF NOT EXISTS %s" % SCHEMA)
|
|
88
|
+
cur.execute("""CREATE TABLE IF NOT EXISTS %s.watermark (
|
|
89
|
+
module text NOT NULL,
|
|
90
|
+
entity text NOT NULL,
|
|
91
|
+
source text NOT NULL,
|
|
92
|
+
version bytea NOT NULL,
|
|
93
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
94
|
+
PRIMARY KEY (module, entity))""" % SCHEMA)
|
|
95
|
+
# Entities whose rows a delta moved and whose module has not been settled since - its
|
|
96
|
+
# placeholders, balances and markers. Written in the same transaction as the rows, so a
|
|
97
|
+
# run that dies between the two leaves the next one something to finish.
|
|
98
|
+
cur.execute("""CREATE TABLE IF NOT EXISTS %s.unsettled (
|
|
99
|
+
module text NOT NULL,
|
|
100
|
+
entity text NOT NULL,
|
|
101
|
+
since timestamptz NOT NULL DEFAULT now(),
|
|
102
|
+
PRIMARY KEY (module, entity))""" % SCHEMA)
|
|
103
|
+
_READY.add(id(pg))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def unsettle(pg, module, job):
|
|
107
|
+
pg.cursor().execute("INSERT INTO %s.unsettled (module, entity) VALUES (%%s, %%s)"
|
|
108
|
+
" ON CONFLICT DO NOTHING" % SCHEMA, (module, job.entity))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def unsettled(pg, module):
|
|
112
|
+
ensure(pg)
|
|
113
|
+
cur = pg.cursor()
|
|
114
|
+
cur.execute("SELECT entity FROM %s.unsettled WHERE module = %%s" % SCHEMA, (module,))
|
|
115
|
+
return set(r[0] for r in cur.fetchall())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def record(pg, module, job, version, lower_only=False):
|
|
119
|
+
"""Set an entity's mark, inside the caller's transaction."""
|
|
120
|
+
ensure(pg)
|
|
121
|
+
cur = pg.cursor()
|
|
122
|
+
cur.execute("""INSERT INTO {s}.watermark AS w (module, entity, source, version)
|
|
123
|
+
VALUES (%s, %s, %s, %s)
|
|
124
|
+
ON CONFLICT (module, entity) DO UPDATE
|
|
125
|
+
SET version = {v}, source = EXCLUDED.source, updated_at = now()"""
|
|
126
|
+
.format(s=SCHEMA, v="LEAST(w.version, EXCLUDED.version)" if lower_only
|
|
127
|
+
else "EXCLUDED.version"),
|
|
128
|
+
(module, job.entity, job.source, as_binary(version)))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def marks(pg):
|
|
132
|
+
ensure(pg)
|
|
133
|
+
cur = pg.cursor()
|
|
134
|
+
cur.execute("SELECT module, entity, version, updated_at FROM %s.watermark" % SCHEMA)
|
|
135
|
+
return dict(((m, e), (bytes(v), at)) for m, e, v, at in cur.fetchall())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def as_binary(value):
|
|
139
|
+
import psycopg2
|
|
140
|
+
return psycopg2.Binary(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def hexed(raw):
|
|
144
|
+
return "0x" + bytes(raw).hex()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------------------- classification
|
|
148
|
+
|
|
149
|
+
def classify(job, source, mapped):
|
|
150
|
+
"""(kind, detail) for one planned entity. See the module docstring for the four kinds.
|
|
151
|
+
|
|
152
|
+
An entity whose query joins in a column from ANOTHER table - the latest status of a
|
|
153
|
+
shipping order, read off an information register that other documents write - is read
|
|
154
|
+
whole: its row changes when that table does, and the owner's `_Version` does not move.
|
|
155
|
+
"""
|
|
156
|
+
if mapped.get("joined"):
|
|
157
|
+
return "whole", None
|
|
158
|
+
columns = source[job.source]["columns"]
|
|
159
|
+
versioned = (columns.get("_Version") or {}).get("type") == "timestamp"
|
|
160
|
+
exposes = not job.query or re.search(r"\b\w+\.\*", job.query)
|
|
161
|
+
keys = [c for c, s in job.pairs if s == "_IDRRef"]
|
|
162
|
+
if versioned and keys and exposes:
|
|
163
|
+
return "object", keys[0]
|
|
164
|
+
for column, src in job.pairs:
|
|
165
|
+
match = OWNER.match(src)
|
|
166
|
+
if match and (source.get(match.group(1), {}).get("columns", {})
|
|
167
|
+
.get("_Version", {}).get("type") == "timestamp"):
|
|
168
|
+
return "lines", (column, src, match.group(1))
|
|
169
|
+
recorder = [c for c, s in job.pairs if s == "_RecorderRRef"]
|
|
170
|
+
if recorder:
|
|
171
|
+
return "movements", (recorder[0], "_RecorderTRef" in columns)
|
|
172
|
+
return "whole", None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def table_number(name):
|
|
176
|
+
return int(re.search(r"(\d+)$", name).group(1))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------- what 1C holds
|
|
180
|
+
|
|
181
|
+
class Infobase(object):
|
|
182
|
+
"""Every object key 1C holds and its `_Version`, read once per run.
|
|
183
|
+
|
|
184
|
+
2.7 M keys across 472 tables; the read is a few seconds and is the whole of the change
|
|
185
|
+
detection for objects, lines and movements.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, export, source, since):
|
|
189
|
+
self.versions = {} # table -> [(raw key, raw version)] at or after `since`
|
|
190
|
+
self.now = store.horizon()
|
|
191
|
+
started = time.time()
|
|
192
|
+
self.keys = 0
|
|
193
|
+
self.alive = []
|
|
194
|
+
for name, info in sorted(source.items()):
|
|
195
|
+
if not OBJECT.match(name):
|
|
196
|
+
continue
|
|
197
|
+
if (info["columns"].get("_Version") or {}).get("type") != "timestamp":
|
|
198
|
+
continue
|
|
199
|
+
if "_IDRRef" not in info["columns"]:
|
|
200
|
+
continue
|
|
201
|
+
export.execute('SELECT "_IDRRef", "_Version" FROM %s WHERE "_Version" >= %s'
|
|
202
|
+
% (store.quote(name), store.literal(since)))
|
|
203
|
+
self.versions[name] = [(bytes(k), bytes(v)) for k, v in export.fetchall()]
|
|
204
|
+
export.execute('SELECT "_IDRRef" FROM %s' % store.quote(name))
|
|
205
|
+
while True:
|
|
206
|
+
batch = export.fetchmany(50000)
|
|
207
|
+
if not batch:
|
|
208
|
+
break
|
|
209
|
+
self.keys += len(batch)
|
|
210
|
+
self.alive.extend(bytes(r[0]) for r in batch)
|
|
211
|
+
self.seconds = time.time() - started
|
|
212
|
+
|
|
213
|
+
def changed(self, table, mark):
|
|
214
|
+
return [k for k, v in self.versions.get(table, ()) if v >= mark]
|
|
215
|
+
|
|
216
|
+
def changed_documents(self, mark):
|
|
217
|
+
"""{table number: [raw key]} over every document table: the recorders that moved."""
|
|
218
|
+
out = {}
|
|
219
|
+
for name, rows in self.versions.items():
|
|
220
|
+
if not name.startswith("_Document"):
|
|
221
|
+
continue
|
|
222
|
+
keys = [k for k, v in rows if v >= mark]
|
|
223
|
+
if keys:
|
|
224
|
+
out[table_number(name)] = keys
|
|
225
|
+
# Recorders whose accounting entries were switched on or off: the document was not
|
|
226
|
+
# written, so its `_Version` says nothing, and `dump1c data --changes` names them.
|
|
227
|
+
# A run ending AT the mark counts too: entries have no rowversion, so switching them
|
|
228
|
+
# does not move the horizon, and a run can end on the very mark a load was taken at.
|
|
229
|
+
# Taking one too many only re-reads recorders that then compare unchanged.
|
|
230
|
+
for run in store.manifest().get("changes", []):
|
|
231
|
+
if bytes.fromhex(run["to"]) < mark:
|
|
232
|
+
continue
|
|
233
|
+
for table in run["tables"].values():
|
|
234
|
+
for number, keys in (table.get("switched") or {}).items():
|
|
235
|
+
held = out.setdefault(int(number), [])
|
|
236
|
+
seen = set(held)
|
|
237
|
+
held.extend(k for k in map(bytes.fromhex, keys) if k not in seen)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
def load_alive(self, pg):
|
|
241
|
+
"""The key set into a temporary table, for the anti-joins that find deletions."""
|
|
242
|
+
started = time.time()
|
|
243
|
+
cur = pg.cursor()
|
|
244
|
+
cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_alive")
|
|
245
|
+
cur.execute("CREATE TEMP TABLE df1c_alive (id uuid)")
|
|
246
|
+
for at in range(0, len(self.alive), 200000):
|
|
247
|
+
migrate.copy_rows(pg, "pg_temp", "df1c_alive", ["id"],
|
|
248
|
+
[[migrate.to_uuid(k)] for k in self.alive[at:at + 200000]])
|
|
249
|
+
cur.execute("CREATE UNIQUE INDEX ON pg_temp.df1c_alive (id)")
|
|
250
|
+
cur.execute("ANALYZE pg_temp.df1c_alive")
|
|
251
|
+
return time.time() - started
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --------------------------------------------------------------------------- staging
|
|
255
|
+
|
|
256
|
+
def q(name):
|
|
257
|
+
return '"%s"' % name.replace('"', '""')
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def stage(pg, export, job, columns, restricts):
|
|
261
|
+
"""Read the rows `restricts` select from 1C through the load's own conversions into a
|
|
262
|
+
temporary table shaped like the target. `restricts` is a list of alias -> condition
|
|
263
|
+
functions, each one query (a long key list is split), or [None] for the whole source."""
|
|
264
|
+
cur = pg.cursor()
|
|
265
|
+
cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_stage")
|
|
266
|
+
cur.execute('CREATE TEMP TABLE df1c_stage AS SELECT %s FROM "%s"."%s" WITH NO DATA'
|
|
267
|
+
% (", ".join(q(c) for c in columns), job.schema, job.table))
|
|
268
|
+
select = ", ".join(store.quote(p[1]) for p in job.pairs)
|
|
269
|
+
staged = 0
|
|
270
|
+
for restrict in restricts:
|
|
271
|
+
export.execute("SELECT %s FROM %s" % (select, migrate.reading(job, restrict)))
|
|
272
|
+
while True:
|
|
273
|
+
batch = export.fetchmany(migrate.CHUNK)
|
|
274
|
+
if not batch:
|
|
275
|
+
break
|
|
276
|
+
rows = migrate.converted(job, batch)
|
|
277
|
+
migrate.copy_rows(pg, "pg_temp", "df1c_stage", columns, rows)
|
|
278
|
+
staged += len(rows)
|
|
279
|
+
cur.execute("ANALYZE pg_temp.df1c_stage")
|
|
280
|
+
return staged
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def in_lists(keys, column, alias):
|
|
284
|
+
"""`alias.column IN (from_hex(..), ...)`, one condition per slice of the key list."""
|
|
285
|
+
out = []
|
|
286
|
+
for at in range(0, len(keys), KEYS_IN_LIST):
|
|
287
|
+
listed = ", ".join(store.literal(k) for k in keys[at:at + KEYS_IN_LIST])
|
|
288
|
+
out.append(lambda a, listed=listed: "%s.%s IN (%s)" % (a, column, listed))
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def recorder_lists(changed, typed):
|
|
293
|
+
"""Restrictions selecting the movements of the changed recorders, through the recorder
|
|
294
|
+
index: by (TRef, RRef) where the register is written by several kinds, by RRef where it
|
|
295
|
+
is written by one."""
|
|
296
|
+
if not typed:
|
|
297
|
+
keys = [k for group in changed.values() for k in group]
|
|
298
|
+
return in_lists(keys, "_RecorderRRef", None)
|
|
299
|
+
out = []
|
|
300
|
+
for number, keys in sorted(changed.items()):
|
|
301
|
+
tref = store.literal(struct.pack(">I", number))
|
|
302
|
+
for cond in in_lists(keys, "_RecorderRRef", None):
|
|
303
|
+
out.append(lambda a, tref=tref, cond=cond:
|
|
304
|
+
"%s._RecorderTRef = %s AND %s" % (a, tref, cond(a)))
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def texts(columns, alias):
|
|
309
|
+
return ", ".join("%s.%s::text" % (alias, q(c)) for c in columns)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --------------------------------------------------------------------------- one entity
|
|
313
|
+
|
|
314
|
+
class Result(object):
|
|
315
|
+
def __init__(self, module, job, kind):
|
|
316
|
+
self.module, self.job, self.kind = module, job, kind
|
|
317
|
+
self.changed = self.new = self.deleted = 0
|
|
318
|
+
self.owners = 0 # lines/movements: owners or recorders replaced
|
|
319
|
+
self.staged = 0
|
|
320
|
+
self.read = self.compare = self.write = 0.0
|
|
321
|
+
self.note = ""
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def touched(self):
|
|
325
|
+
return bool(self.changed or self.new or self.deleted)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def has_missing(pg, job):
|
|
329
|
+
return any(c[0] == "is_missing" for c in migrate.table_columns(pg, job.schema, job.table))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def run_entity(pg, export, lookup, base, module, job, kind, detail, mark, apply, now):
|
|
333
|
+
res = Result(module, job, kind)
|
|
334
|
+
job = migrate.bind_enums(job, lookup)
|
|
335
|
+
columns = [p[0] for p in job.pairs] + ([job.derived] if job.derived else [])
|
|
336
|
+
keys = sorted(migrate.primary_key(pg, job.schema, job.table))
|
|
337
|
+
missing = has_missing(pg, job)
|
|
338
|
+
real = " AND NOT t.is_missing" if missing else ""
|
|
339
|
+
target = '"%s"."%s"' % (job.schema, job.table)
|
|
340
|
+
cur = pg.cursor()
|
|
341
|
+
started = time.time()
|
|
342
|
+
|
|
343
|
+
if kind == "object":
|
|
344
|
+
key_column = detail
|
|
345
|
+
res.staged = stage(pg, export, job, columns,
|
|
346
|
+
[lambda a: "%s._Version >= %s" % (a, store.literal(mark))])
|
|
347
|
+
res.read = time.time() - started
|
|
348
|
+
started = time.time()
|
|
349
|
+
on = " AND ".join("t.%s = s.%s" % (q(k), q(k)) for k in keys)
|
|
350
|
+
cur.execute("SELECT count(*) FILTER (WHERE t.%s IS NULL%s),"
|
|
351
|
+
" count(*) FILTER (WHERE t.%s IS NOT NULL%s AND ROW(%s) IS DISTINCT FROM ROW(%s))"
|
|
352
|
+
" FROM pg_temp.df1c_stage s LEFT JOIN %s t ON %s"
|
|
353
|
+
% (q(keys[0]), " OR t.is_missing" if missing else "",
|
|
354
|
+
q(keys[0]), " AND NOT t.is_missing" if missing else "",
|
|
355
|
+
texts(columns, "s"), texts(columns, "t"), target, on))
|
|
356
|
+
res.new, res.changed = cur.fetchone()
|
|
357
|
+
gone = ("FROM %s t WHERE NOT EXISTS (SELECT 1 FROM pg_temp.df1c_alive k"
|
|
358
|
+
" WHERE k.id = t.%s)%s" % (target, q(key_column), real))
|
|
359
|
+
cur.execute("SELECT count(*) " + gone)
|
|
360
|
+
res.deleted = cur.fetchone()[0]
|
|
361
|
+
res.compare = time.time() - started
|
|
362
|
+
if apply and res.touched:
|
|
363
|
+
started = time.time()
|
|
364
|
+
begin(cur)
|
|
365
|
+
updates = [c for c in columns if c not in keys]
|
|
366
|
+
sets = ", ".join("%s = EXCLUDED.%s" % (q(c), q(c)) for c in updates)
|
|
367
|
+
if missing:
|
|
368
|
+
sets += (", " if sets else "") + "is_missing = false"
|
|
369
|
+
into = columns + (["is_missing"] if missing else [])
|
|
370
|
+
cur.execute("INSERT INTO %s AS t (%s) SELECT %s%s FROM pg_temp.df1c_stage"
|
|
371
|
+
" ON CONFLICT (%s) DO %s"
|
|
372
|
+
% (target, ", ".join(q(c) for c in into),
|
|
373
|
+
", ".join(q(c) for c in columns), ", false" if missing else "",
|
|
374
|
+
", ".join(q(k) for k in keys),
|
|
375
|
+
("UPDATE SET %s WHERE ROW(%s%s) IS DISTINCT FROM ROW(%s%s)"
|
|
376
|
+
% (sets, texts(columns, "t"), ", t.is_missing" if missing else "",
|
|
377
|
+
", ".join("EXCLUDED.%s::text" % q(c) for c in columns),
|
|
378
|
+
", false" if missing else "")) if sets else "NOTHING"))
|
|
379
|
+
cur.execute("DELETE " + gone)
|
|
380
|
+
unsettle(pg, module, job)
|
|
381
|
+
record(pg, module, job, now)
|
|
382
|
+
cur.execute("COMMIT")
|
|
383
|
+
res.write = time.time() - started
|
|
384
|
+
elif apply:
|
|
385
|
+
begin(cur)
|
|
386
|
+
record(pg, module, job, now)
|
|
387
|
+
cur.execute("COMMIT")
|
|
388
|
+
return res
|
|
389
|
+
|
|
390
|
+
if kind in ("lines", "movements"):
|
|
391
|
+
if kind == "lines":
|
|
392
|
+
owner, owner_src, owner_table = detail
|
|
393
|
+
changed = base.changed(owner_table, mark)
|
|
394
|
+
restricts = in_lists(changed, owner_src, None)
|
|
395
|
+
changed_ids = [migrate.to_uuid(k) for k in changed]
|
|
396
|
+
else:
|
|
397
|
+
owner, typed = detail
|
|
398
|
+
by_type = base.changed_documents(mark)
|
|
399
|
+
restricts = recorder_lists(by_type, typed)
|
|
400
|
+
changed_ids = [migrate.to_uuid(k) for group in by_type.values() for k in group]
|
|
401
|
+
# Owners the target holds that 1C no longer has: their rows go too.
|
|
402
|
+
cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_owners")
|
|
403
|
+
cur.execute("CREATE TEMP TABLE df1c_owners (id uuid)")
|
|
404
|
+
started_gone = time.time()
|
|
405
|
+
# The anti-join first and the DISTINCT over what survives it - next to nothing -
|
|
406
|
+
# rather than a DISTINCT over the whole table: 75 s against 24 M postings otherwise.
|
|
407
|
+
# More memory for this one statement only. Left on the session it outlived its
|
|
408
|
+
# purpose and a later parallel hash join asked for more shared memory than the
|
|
409
|
+
# container's /dev/shm has ("could not resize shared memory segment").
|
|
410
|
+
cur.execute("SET work_mem = '256MB'")
|
|
411
|
+
cur.execute("INSERT INTO pg_temp.df1c_owners SELECT DISTINCT g.id FROM"
|
|
412
|
+
" (SELECT t.%s AS id FROM %s t WHERE t.%s IS NOT NULL AND NOT EXISTS"
|
|
413
|
+
" (SELECT 1 FROM pg_temp.df1c_alive k WHERE k.id = t.%s) OFFSET 0) g"
|
|
414
|
+
% (q(owner), target, q(owner), q(owner)))
|
|
415
|
+
gone_owners = cur.rowcount
|
|
416
|
+
cur.execute("RESET work_mem")
|
|
417
|
+
gone_seconds = time.time() - started_gone
|
|
418
|
+
if changed_ids:
|
|
419
|
+
migrate.copy_rows(pg, "pg_temp", "df1c_owners", ["id"], [[i] for i in changed_ids])
|
|
420
|
+
cur.execute("CREATE INDEX ON pg_temp.df1c_owners (id)")
|
|
421
|
+
cur.execute("ANALYZE pg_temp.df1c_owners")
|
|
422
|
+
res.owners = len(changed_ids) + gone_owners
|
|
423
|
+
res.staged = stage(pg, export, job, columns, restricts) if restricts else 0
|
|
424
|
+
if not restricts:
|
|
425
|
+
cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_stage")
|
|
426
|
+
cur.execute('CREATE TEMP TABLE df1c_stage AS SELECT %s FROM %s WITH NO DATA'
|
|
427
|
+
% (", ".join(q(c) for c in columns), target))
|
|
428
|
+
res.read = time.time() - started - gone_seconds
|
|
429
|
+
started = time.time()
|
|
430
|
+
# What the target holds for those owners, taken once: the table may have no index on
|
|
431
|
+
# the owner column (the ledger has none on its recorder), and both directions of the
|
|
432
|
+
# comparison read it.
|
|
433
|
+
cur.execute("DROP TABLE IF EXISTS pg_temp.df1c_old")
|
|
434
|
+
cur.execute("CREATE TEMP TABLE df1c_old AS SELECT %s FROM %s t"
|
|
435
|
+
" WHERE t.%s IN (SELECT id FROM pg_temp.df1c_owners)"
|
|
436
|
+
% (", ".join("t.%s" % q(c) for c in columns), target, q(owner)))
|
|
437
|
+
subset = "SELECT %s FROM pg_temp.df1c_old t" % texts(columns, "t")
|
|
438
|
+
staged = "SELECT %s FROM pg_temp.df1c_stage s" % texts(columns, "s")
|
|
439
|
+
cur.execute("SELECT (SELECT count(*) FROM (%s EXCEPT ALL %s) a),"
|
|
440
|
+
" (SELECT count(*) FROM (%s EXCEPT ALL %s) b)"
|
|
441
|
+
% (staged, subset, subset, staged))
|
|
442
|
+
res.new, res.deleted = cur.fetchone()
|
|
443
|
+
res.compare = time.time() - started + gone_seconds
|
|
444
|
+
if apply and res.touched:
|
|
445
|
+
started = time.time()
|
|
446
|
+
begin(cur)
|
|
447
|
+
cur.execute("DELETE FROM %s t WHERE t.%s IN (SELECT id FROM pg_temp.df1c_owners)"
|
|
448
|
+
% (target, q(owner)))
|
|
449
|
+
cur.execute("INSERT INTO %s (%s) SELECT %s FROM pg_temp.df1c_stage"
|
|
450
|
+
% (target, ", ".join(q(c) for c in columns),
|
|
451
|
+
", ".join(q(c) for c in columns)))
|
|
452
|
+
unsettle(pg, module, job)
|
|
453
|
+
record(pg, module, job, now)
|
|
454
|
+
cur.execute("COMMIT")
|
|
455
|
+
res.write = time.time() - started
|
|
456
|
+
elif apply:
|
|
457
|
+
begin(cur)
|
|
458
|
+
record(pg, module, job, now)
|
|
459
|
+
cur.execute("COMMIT")
|
|
460
|
+
return res
|
|
461
|
+
|
|
462
|
+
# whole: read it all, compare by key.
|
|
463
|
+
res.staged = stage(pg, export, job, columns, [None])
|
|
464
|
+
res.read = time.time() - started
|
|
465
|
+
started = time.time()
|
|
466
|
+
on = " AND ".join("t.%s = s.%s" % (q(k), q(k)) for k in keys)
|
|
467
|
+
cur.execute("SELECT count(*) FILTER (WHERE t.%s IS NULL%s),"
|
|
468
|
+
" count(*) FILTER (WHERE t.%s IS NOT NULL%s AND ROW(%s) IS DISTINCT FROM ROW(%s))"
|
|
469
|
+
" FROM pg_temp.df1c_stage s LEFT JOIN %s t ON %s"
|
|
470
|
+
% (q(keys[0]), " OR t.is_missing" if missing else "",
|
|
471
|
+
q(keys[0]), " AND NOT t.is_missing" if missing else "",
|
|
472
|
+
texts(columns, "s"), texts(columns, "t"), target, on))
|
|
473
|
+
res.new, res.changed = cur.fetchone()
|
|
474
|
+
gone = ("FROM %s t WHERE NOT EXISTS (SELECT 1 FROM pg_temp.df1c_stage s WHERE %s)%s"
|
|
475
|
+
% (target, on, real))
|
|
476
|
+
cur.execute("SELECT count(*) " + gone)
|
|
477
|
+
res.deleted = cur.fetchone()[0]
|
|
478
|
+
res.compare = time.time() - started
|
|
479
|
+
if apply and res.touched:
|
|
480
|
+
started = time.time()
|
|
481
|
+
begin(cur)
|
|
482
|
+
updates = [c for c in columns if c not in keys]
|
|
483
|
+
sets = ", ".join("%s = EXCLUDED.%s" % (q(c), q(c)) for c in updates)
|
|
484
|
+
if missing:
|
|
485
|
+
sets += (", " if sets else "") + "is_missing = false"
|
|
486
|
+
into = columns + (["is_missing"] if missing else [])
|
|
487
|
+
cur.execute("DELETE " + gone)
|
|
488
|
+
cur.execute("INSERT INTO %s AS t (%s) SELECT %s%s FROM pg_temp.df1c_stage"
|
|
489
|
+
" ON CONFLICT (%s) DO %s"
|
|
490
|
+
% (target, ", ".join(q(c) for c in into),
|
|
491
|
+
", ".join(q(c) for c in columns), ", false" if missing else "",
|
|
492
|
+
", ".join(q(k) for k in keys),
|
|
493
|
+
("UPDATE SET %s WHERE ROW(%s) IS DISTINCT FROM ROW(%s)"
|
|
494
|
+
% (sets, texts(columns, "t"),
|
|
495
|
+
", ".join("EXCLUDED.%s::text" % q(c) for c in columns)))
|
|
496
|
+
if sets else "NOTHING"))
|
|
497
|
+
unsettle(pg, module, job)
|
|
498
|
+
record(pg, module, job, now)
|
|
499
|
+
cur.execute("COMMIT")
|
|
500
|
+
res.write = time.time() - started
|
|
501
|
+
elif apply:
|
|
502
|
+
begin(cur)
|
|
503
|
+
record(pg, module, job, now)
|
|
504
|
+
cur.execute("COMMIT")
|
|
505
|
+
return res
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def begin(cur):
|
|
509
|
+
"""One transaction per entity, in replica mode as the load runs: the generated lock
|
|
510
|
+
and posted-document guards would refuse exactly these rows, and so would a foreign key
|
|
511
|
+
to a row another module brings in later in the same delta."""
|
|
512
|
+
cur.execute("BEGIN")
|
|
513
|
+
cur.execute("SET LOCAL session_replication_role = 'replica'")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# --------------------------------------------------------------------------- after a module
|
|
517
|
+
|
|
518
|
+
def settle(pg, module, results, apply, mark_documents):
|
|
519
|
+
"""What a module needs once its rows moved: placeholders, balances, markers."""
|
|
520
|
+
import posted
|
|
521
|
+
import seedstate
|
|
522
|
+
touched = set(r.job.entity for r in results if r.touched)
|
|
523
|
+
if apply:
|
|
524
|
+
touched |= unsettled(pg, module)
|
|
525
|
+
schemas = sorted(set(r.job.schema for r in results))
|
|
526
|
+
notes = []
|
|
527
|
+
settled = True
|
|
528
|
+
if apply and touched:
|
|
529
|
+
cur = pg.cursor()
|
|
530
|
+
cur.execute("BEGIN")
|
|
531
|
+
cur.execute("SET LOCAL session_replication_role = 'replica'")
|
|
532
|
+
filled = migrate.fill_missing(pg, schemas, verbose=False)
|
|
533
|
+
cur.execute("COMMIT")
|
|
534
|
+
if filled:
|
|
535
|
+
notes.append("%d placeholder(s)" % sum(f[4] for f in filled))
|
|
536
|
+
|
|
537
|
+
cur = pg.cursor()
|
|
538
|
+
for reg in seedstate.registers(cur, set([module])):
|
|
539
|
+
if reg.journal_entity not in touched:
|
|
540
|
+
continue
|
|
541
|
+
if apply:
|
|
542
|
+
pg.autocommit = False
|
|
543
|
+
rows, written, _ = seedstate.seed(pg, reg)
|
|
544
|
+
pg.autocommit = True
|
|
545
|
+
off = seedstate.drift(pg.cursor(), reg)
|
|
546
|
+
settled = settled and not off
|
|
547
|
+
notes.append("reseeded %s.%s: %d tuple(s), check %s"
|
|
548
|
+
% (reg.module, reg.code, written,
|
|
549
|
+
"clean" if not off else "OFF BY %d" % off))
|
|
550
|
+
else:
|
|
551
|
+
notes.append("would reseed %s.%s" % (reg.module, reg.code))
|
|
552
|
+
|
|
553
|
+
# A totals cache is not maintained by a bulk write any more than by the load: the
|
|
554
|
+
# platform folds movements in only at posting. `totals` refills it from the journal with
|
|
555
|
+
# the platform's own SQL, transcribed, and checks it the platform's way.
|
|
556
|
+
cur.execute("""SELECT r.register_cd, je.entity_cd FROM "dForge".register r
|
|
557
|
+
JOIN "dForge".module m ON m.module_id = r.module_id
|
|
558
|
+
JOIN "dForge".entity je ON je.entity_id = r.journal_entity_id
|
|
559
|
+
WHERE m.module_cd = %s AND r.totals IS NOT NULL""", (module,))
|
|
560
|
+
stale = set(code for code, journal in cur.fetchall() if journal in touched)
|
|
561
|
+
if stale:
|
|
562
|
+
import totals
|
|
563
|
+
for plan in totals.plans(pg.cursor(), set([module])):
|
|
564
|
+
if plan.code not in stale:
|
|
565
|
+
continue
|
|
566
|
+
if not apply:
|
|
567
|
+
notes.append("would rebuild the totals cache of %s.%s" % (module, plan.code))
|
|
568
|
+
continue
|
|
569
|
+
totals.rebuild(pg, plan)
|
|
570
|
+
_, off = totals.check(pg, plan, 5)
|
|
571
|
+
settled = settled and not off
|
|
572
|
+
notes.append("rebuilt the totals cache of %s.%s, check %s"
|
|
573
|
+
% (module, plan.code, "clean" if not off else "OFF BY %d" % off))
|
|
574
|
+
|
|
575
|
+
work, _, _ = posted.survey(pg, module, quiet=True)
|
|
576
|
+
work = [w for w in work if w[1].split(".", 1)[1] in touched]
|
|
577
|
+
if work:
|
|
578
|
+
if apply and mark_documents:
|
|
579
|
+
done, ok = posted.converge(pg, work, mark_documents)
|
|
580
|
+
settled = settled and ok
|
|
581
|
+
notes.append("markers: %s%s" % (", ".join("%s %d" % kv for kv in done.items()) or
|
|
582
|
+
"nothing to do", "" if ok else " (FAILED)"))
|
|
583
|
+
elif apply:
|
|
584
|
+
settled = False
|
|
585
|
+
notes.append("markers NOT converged (no token): run `posted --mark %s`, or this"
|
|
586
|
+
" again with a token" % module)
|
|
587
|
+
else:
|
|
588
|
+
notes.append("would converge markers on %d document table(s)" % len(work))
|
|
589
|
+
if apply and touched and settled:
|
|
590
|
+
pg.cursor().execute("DELETE FROM %s.unsettled WHERE module = %%s" % SCHEMA, (module,))
|
|
591
|
+
return notes
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# --------------------------------------------------------------------------- command
|
|
595
|
+
|
|
596
|
+
def since_date(export, source, day):
|
|
597
|
+
"""A watermark for estimation: the lowest `_Version` of any document dated on or after
|
|
598
|
+
`day`. Everything 1C wrote from about then on is at or above it."""
|
|
599
|
+
shifted = "%04d-%s" % (int(day[:4]) + store.year_offset(), day[5:])
|
|
600
|
+
lowest = None
|
|
601
|
+
for name, info in source.items():
|
|
602
|
+
if not name.startswith("_Document") or not OBJECT.match(name):
|
|
603
|
+
continue
|
|
604
|
+
if "_Date_Time" not in info["columns"] or \
|
|
605
|
+
(info["columns"].get("_Version") or {}).get("type") != "timestamp":
|
|
606
|
+
continue
|
|
607
|
+
export.execute("SELECT MIN(_Version) FROM %s WHERE _Date_Time >= '%s'"
|
|
608
|
+
% (store.quote(name), shifted))
|
|
609
|
+
found = export.fetchone()[0]
|
|
610
|
+
if found is not None and (lowest is None or bytes(found) < lowest):
|
|
611
|
+
lowest = bytes(found)
|
|
612
|
+
if lowest is None:
|
|
613
|
+
raise SystemExit("delta: no document dated on or after %s" % day)
|
|
614
|
+
return lowest
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def modules_in_order(mapping):
|
|
618
|
+
try:
|
|
619
|
+
import rebuild
|
|
620
|
+
order = [m for m in rebuild.install_order() if m in mapping]
|
|
621
|
+
except SystemExit:
|
|
622
|
+
order = []
|
|
623
|
+
return order + [m for m in mapping if m not in order]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def main():
|
|
627
|
+
migrate.load_env()
|
|
628
|
+
ap = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
|
|
629
|
+
ap.add_argument("module", nargs="*", help="limit to these modules (default: every one)")
|
|
630
|
+
ap.add_argument("--entity", action="append", help="limit to this entity (repeatable)")
|
|
631
|
+
ap.add_argument("--apply", action="store_true", help="write the delta (default: dry run)")
|
|
632
|
+
ap.add_argument("--dry-run", action="store_true", help="report only (the default)")
|
|
633
|
+
ap.add_argument("--since", help="override every watermark: YYYY-MM-DD (estimation) or 0x...")
|
|
634
|
+
ap.add_argument("--baseline", action="store_true",
|
|
635
|
+
help="set the mark of every entity that has none to 1C's horizon now")
|
|
636
|
+
ap.add_argument("--status", action="store_true", help="list the watermarks")
|
|
637
|
+
ap.add_argument("--no-mark", action="store_true",
|
|
638
|
+
help="--apply without converging the posted markers")
|
|
639
|
+
args = ap.parse_args()
|
|
640
|
+
if args.apply and args.dry_run:
|
|
641
|
+
raise SystemExit("delta: --apply and --dry-run together")
|
|
642
|
+
|
|
643
|
+
mapping, source = migrate.column_map(), migrate.source_schema()
|
|
644
|
+
pg = migrate.pg_connect()
|
|
645
|
+
pg.autocommit = True # no read transaction held open on the metadata tables
|
|
646
|
+
modules = [m for m in modules_in_order(mapping) if not args.module or m in args.module]
|
|
647
|
+
unknown = set(args.module) - set(mapping)
|
|
648
|
+
if unknown:
|
|
649
|
+
raise SystemExit("delta: not in the column map: %s" % ", ".join(sorted(unknown)))
|
|
650
|
+
known = marks(pg)
|
|
651
|
+
|
|
652
|
+
if args.status:
|
|
653
|
+
for (module, entity), (version, at) in sorted(known.items()):
|
|
654
|
+
if module in modules:
|
|
655
|
+
print("%-12s %-40s %s %s" % (module, entity, hexed(version), at))
|
|
656
|
+
return 0
|
|
657
|
+
|
|
658
|
+
if store.horizon() is None:
|
|
659
|
+
raise SystemExit("delta: the export in %s has no horizon - %s.\n"
|
|
660
|
+
" Reload instead: `dump1c data`, then `migrate run <module> --truncate`."
|
|
661
|
+
% (paths.OUT, "a file infobase's row versions are per table"
|
|
662
|
+
if store.source_kind() == "file" else "a table in it was exported without one"))
|
|
663
|
+
# Every object table is read for its keys - a deletion is a key 1C no longer holds, and
|
|
664
|
+
# a recorder is any object - so one left out of the export is every row it owns taken
|
|
665
|
+
# for deleted. Asked here, by name, rather than as DuckDB's "Table _Acc33 does not exist".
|
|
666
|
+
exported = store.manifest()["tables"]
|
|
667
|
+
missing = sorted(n for n, info in source.items() if OBJECT.match(n) and n not in exported
|
|
668
|
+
and (info["columns"].get("_Version") or {}).get("type") == "timestamp")
|
|
669
|
+
if missing:
|
|
670
|
+
raise SystemExit("delta: %d object table(s) are not in the export (%s%s) - every object's keys"
|
|
671
|
+
" are needed.\n `1c.py dump1c data --objects` exports them all."
|
|
672
|
+
% (len(missing), ", ".join(missing[:5]), ", ..." if len(missing) > 5 else ""))
|
|
673
|
+
db = migrate.source_connect()
|
|
674
|
+
export, lookup = db.cursor(), db.cursor()
|
|
675
|
+
plan = []
|
|
676
|
+
for module in modules:
|
|
677
|
+
for job in migrate.jobs_for(pg, module, mapping, source, args.entity):
|
|
678
|
+
if job.blocking:
|
|
679
|
+
continue
|
|
680
|
+
plan.append((module, job))
|
|
681
|
+
|
|
682
|
+
if args.baseline:
|
|
683
|
+
now = store.horizon()
|
|
684
|
+
set_ = 0
|
|
685
|
+
cur = pg.cursor()
|
|
686
|
+
cur.execute("BEGIN")
|
|
687
|
+
for module, job in plan:
|
|
688
|
+
if (module, job.entity) not in known:
|
|
689
|
+
record(pg, module, job, now)
|
|
690
|
+
set_ += 1
|
|
691
|
+
cur.execute("COMMIT")
|
|
692
|
+
print("baseline %s set on %d entit(ies); %d already had one."
|
|
693
|
+
% (hexed(now), set_, len(plan) - set_))
|
|
694
|
+
print("Only right if the tenant holds what 1C holds NOW - straight after a full load"
|
|
695
|
+
" during which 1C did not change.")
|
|
696
|
+
return 0
|
|
697
|
+
|
|
698
|
+
override = None
|
|
699
|
+
if args.since:
|
|
700
|
+
override = (bytes.fromhex(args.since[2:]) if args.since.startswith("0x")
|
|
701
|
+
else since_date(export, source, args.since))
|
|
702
|
+
unmarked = [(m, j.entity) for m, j in plan if (m, j.entity) not in known]
|
|
703
|
+
if unmarked and override is None:
|
|
704
|
+
raise SystemExit("delta: %d entit(ies) have no watermark (e.g. %s.%s) - load them with"
|
|
705
|
+
" `migrate run`, or `delta --baseline` if the tenant already matches 1C"
|
|
706
|
+
% (len(unmarked), unmarked[0][0], unmarked[0][1]))
|
|
707
|
+
|
|
708
|
+
def mark_of(module, job):
|
|
709
|
+
return override if override is not None else known[(module, job.entity)][0]
|
|
710
|
+
|
|
711
|
+
lowest = min(mark_of(m, j) for m, j in plan)
|
|
712
|
+
t0 = time.time()
|
|
713
|
+
base = Infobase(export, source, lowest)
|
|
714
|
+
alive_seconds = base.load_alive(pg)
|
|
715
|
+
print("export horizon %s, reading from %s%s" % (hexed(base.now), hexed(lowest),
|
|
716
|
+
" (--since %s)" % args.since if args.since else ""))
|
|
717
|
+
print("object keys: %d read in %.1fs, %.1fs into the tenant"
|
|
718
|
+
% (base.keys, base.seconds, alive_seconds))
|
|
719
|
+
print("%s\n" % ("APPLYING" if args.apply else "dry run - nothing written; --apply writes it"))
|
|
720
|
+
|
|
721
|
+
jwt = None
|
|
722
|
+
if args.apply and not args.no_mark:
|
|
723
|
+
try:
|
|
724
|
+
import posting
|
|
725
|
+
jwt = posting.token()
|
|
726
|
+
except SystemExit as missing:
|
|
727
|
+
print("no token (%s) - markers will be left for `posted --mark`" % missing)
|
|
728
|
+
|
|
729
|
+
header = "%-11s %-34s %-9s %8s %8s %8s %8s %7s %7s %7s" % (
|
|
730
|
+
"module", "entity", "kind", "owners", "changed", "new", "deleted",
|
|
731
|
+
"read s", "cmp s", "write s")
|
|
732
|
+
print(header)
|
|
733
|
+
print("-" * len(header))
|
|
734
|
+
totals = [0, 0, 0, 0, 0.0]
|
|
735
|
+
all_notes = []
|
|
736
|
+
for module in modules:
|
|
737
|
+
results = []
|
|
738
|
+
for m, job in plan:
|
|
739
|
+
if m != module:
|
|
740
|
+
continue
|
|
741
|
+
kind, detail = classify(job, source, mapping[module][job.entity])
|
|
742
|
+
res = run_entity(pg, export, lookup, base, module, job, kind, detail,
|
|
743
|
+
mark_of(module, job), args.apply, base.now)
|
|
744
|
+
results.append(res)
|
|
745
|
+
totals[0] += res.changed
|
|
746
|
+
totals[1] += res.new
|
|
747
|
+
totals[2] += res.deleted
|
|
748
|
+
totals[3] += res.owners
|
|
749
|
+
totals[4] += res.read + res.compare + res.write
|
|
750
|
+
if res.touched or res.owners or res.read + res.compare > 2 or args.entity:
|
|
751
|
+
print("%-11s %-34s %-9s %8s %8d %8d %8d %7.1f %7.1f %7.1f"
|
|
752
|
+
% (module, job.entity[:34], kind,
|
|
753
|
+
res.owners if kind in ("lines", "movements") else "",
|
|
754
|
+
res.changed, res.new, res.deleted, res.read, res.compare, res.write))
|
|
755
|
+
sys.stdout.flush()
|
|
756
|
+
for note in settle(pg, module, results, args.apply, jwt if args.apply else None):
|
|
757
|
+
all_notes.append("%s: %s" % (module, note))
|
|
758
|
+
print("-" * len(header))
|
|
759
|
+
print("%d entities changed %d new %d deleted %d (owners/recorders replaced %d)"
|
|
760
|
+
% (len(plan), totals[0], totals[1], totals[2], totals[3]))
|
|
761
|
+
print("entity work %.0fs, whole run %.0fs" % (totals[4], time.time() - t0))
|
|
762
|
+
for note in all_notes:
|
|
763
|
+
print(" " + note)
|
|
764
|
+
print("(rows of an entity with nothing changed and under 2s are not listed;"
|
|
765
|
+
" lines/movements count rows replaced as new/deleted)")
|
|
766
|
+
return 0
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
if __name__ == "__main__":
|
|
770
|
+
sys.exit(main() or 0)
|