feat: add sync checkpoint restore for last successful sync
Capture rolling online DB snapshots after push/drain/reconcile and expose SyncPage 数据恢复 plus checkpoint/restore APIs; mark Z12h and restore done in coop docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -90,6 +90,9 @@ func agentSyncPushHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
if res != nil && res.Skipped {
|
||||
outcome = "skipped"
|
||||
}
|
||||
if res != nil && res.Applied {
|
||||
dbsync.ScheduleCheckpoint(svcCtx.DBSync.Store(), ch, "push")
|
||||
}
|
||||
logSyncReq(r, "push", channelID, item.Table, item.RowPK, outcome, "", dur, reqID)
|
||||
auditSync(svcCtx, r, "dbsync.push", map[string]any{
|
||||
"channel_id": channelID, "table": item.Table, "row_pk": item.RowPK,
|
||||
@@ -153,6 +156,15 @@ func agentSyncPushBatchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
httpx.OkJson(w, payload)
|
||||
return
|
||||
}
|
||||
applied := 0
|
||||
for _, r0 := range results {
|
||||
if r0.Applied {
|
||||
applied++
|
||||
}
|
||||
}
|
||||
if applied > 0 {
|
||||
dbsync.ScheduleCheckpoint(svcCtx.DBSync.Store(), ch, "push_batch")
|
||||
}
|
||||
logSyncReq(r, "push_batch", channelID, "", "", "ok", "", dur, reqID)
|
||||
auditSync(svcCtx, r, "dbsync.push_batch", map[string]any{
|
||||
"channel_id": channelID, "n": len(body.Items), "ms": dur.Milliseconds(), "req_id": reqID,
|
||||
|
||||
@@ -146,6 +146,8 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
|
||||
{Method: http.MethodGet, Path: "/api/v1/admin/sync/conflicts", Handler: chain(syncConflictsHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/conflicts/:id/resolve", Handler: chain(syncResolveConflictHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/reconcile", Handler: chain(syncReconcileHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodGet, Path: "/api/v1/admin/sync/channels/:id/checkpoint", Handler: chain(syncCheckpointMetaHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/restore", Handler: chain(syncRestoreHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/ingest", Handler: chain(syncIngestHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodGet, Path: "/api/v1/admin/sync/channels/:id/inspect", Handler: chain(syncInspectHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/preview", Handler: chain(syncPreviewHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
|
||||
|
||||
@@ -262,6 +262,7 @@ func syncReconcileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
_ = svcCtx.DBSync.Store().PatchStats(ch.ID, func(c *dbsync.Channel) {
|
||||
c.LastReconcileAt = &now
|
||||
})
|
||||
dbsync.ScheduleCheckpoint(svcCtx.DBSync.Store(), ch, "reconcile")
|
||||
httpx.OkJson(w, res)
|
||||
}
|
||||
}
|
||||
@@ -418,3 +419,62 @@ func syncDropTableHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
httpx.OkJson(w, res)
|
||||
}
|
||||
}
|
||||
|
||||
func syncCheckpointMetaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
id := pathvar.Vars(r)["id"]
|
||||
if _, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r)); err != nil {
|
||||
authx.WriteError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
meta, err := dbsync.LoadCheckpointMeta(svcCtx.DBSync.Store(), id)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, meta)
|
||||
}
|
||||
}
|
||||
|
||||
func syncRestoreHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Which string `json:"which"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !body.Confirm {
|
||||
authx.WriteError(w, http.StatusBadRequest, "请确认恢复:confirm=true")
|
||||
return
|
||||
}
|
||||
which := strings.TrimSpace(body.Which)
|
||||
if which == "" {
|
||||
which = "latest"
|
||||
}
|
||||
res, err := dbsync.RestoreCheckpoint(r.Context(), svcCtx.DBSync.Store(), ch, which)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if svcCtx.Audit != nil {
|
||||
_ = svcCtx.Audit.Log(r.Context(), syncTenantID(r), authx.UserID(r.Context()), "dbsync.restore", audit.DetailJSON(map[string]any{
|
||||
"channel_id": ch.ID, "which": which, "synced_at": res.SyncedAt, "upserted": res.Upserted, "deleted": res.Deleted,
|
||||
}))
|
||||
}
|
||||
httpx.OkJson(w, res)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user