Konkurrierende Veränderungen von Datensätzen

Änderungen zusammenführen

Bei langen Transaktionen wird der gleiche Datensatz möglicherweise von verschiedenen Benutzern verändert. Der Benutzer, der seine Änderungen zuerst abspeichert, merkt nichts von dieser Situation. Allen weiteren Benutzern muss beim Versuch, ihre Änderungen abzuspeichern,

  • angezeigt werden, dass die Daten geändert wurden;
  • die Änderungen anderer Benutzer sichtbar gemacht werden;
  • die eigene Änderung möglichst erhalten bleiben, soweit sie konfliktfrei ist.

Als Grundlage für diese Anzeigen müssen die Daten in der Datenbank mit den Benutzereingaben verglichen und in möglichst sinnvoller Weise zusammengeführt werden.

3 Weg Merge

In der Beispiel-Applikation ist ein 3 Weg Merge implementiert, um Konflikte bei langen Transaktionen aufzulösen. Bei einem 3 Weg Merge werden die parallel entstandenen unterschiedlichen Versionen mit dem gemeinsamen Vorgänger verglichen. Änderungen, die jeweils nur in einer Version des Datensatzes entstanden sind, werden automatisch zusammengeführt. Unterschiedliche Änderungen, die in beiden Versionen entstanden sind, werden als Konflikt markiert. Dieser muss vom Benutzer aufgelöst werden. Die Skizze soll dieses verdeutlichen.

Konkurrierende Änderungen am gleichen Datensatz
Konkurrierende Änderungen am gleichen Datensatz

Der Benutzer greift zum Bearbeiten über die Web Applikation auf Version V.0 eines Objekts zu. Die Applikation speichert diese Version in der Session und lädt sie in den Browser. Hier kann der Benutzer seine Änderungen durchführen (grüne Felder). In der Zwischenzeit wurde das Objekt ein- oder mehrmals in der Datenbank geändert, so dass es dort in Version V.x vorliegt. Der Handler in der Web Applikation kann den POST Request nicht erfolgreich abschließen, da das Objekt nicht in die Datenbank gespeichert werden kann. Der UpdatedAt Zeitstempel der Version in der DB stimmt nicht mit dem Wert des aktuellen Objekts (Version V.1 in der Skizze) überein. Es wird ein Stale Object Error ausgelöst und die Transaktion abgebrochen, siehe Gorm Code für lange Transaktionen. Die Änderungen in Datenbank und POST Request werden jetzt zusammengeführt, markiert und in den Browser geladen. Der Benutzer kann jetzt die Änderungen überprüfen, mögliche Konflikte auflösen und den aktualisierten Datensatz (Version V.x+1) abspeichern.

Werte für den 3 Weg Merge bereitstellen

Drei Versionen eines Objekts werden verglichen, wenn das Update des editierten Objekts einen Fehler ergibt. Im Beispiel sind dies die wichtigen Schritte in der Funktion saveApplicantSubmission in Datei utils.go:

// save edited applicant data to database
func saveApplicantSubmission(w http.ResponseWriter, r *http.Request, maySetResults bool) {
	if err := parseSubmission(w, r); err != nil {
		http.Error(w, "Request parse error: " + err.Error(), http.StatusInternalServerError)
		log.Printf("error %v, status %v\n", "Request parse error: " + err.Error(),
			http.StatusInternalServerError)
		return
	}
	action := html.EscapeString(r.PostFormValue("action"))
	appsession, err := applicantFromSession(action, r)
	if err != nil {
		http.Error(w, "Session store error: " + err.Error(), http.StatusInternalServerError)
		log.Printf("action %s, error %v, status %v\n", action, "Session store error: " + err.Error(),
			http.StatusInternalServerError)
		return
	}
	// copy old applicant from session
	app := appsession
	// update data from form values
	setApplicantData(&app, r)
	switch action {
	case "enrol":
		setEnrolledAt(&app)
	case "results":
		// make sure that results may be modified by current user
		if maySetResults {
			setResultData(&app, r)
		}
	}
	if err := model.Db().Save(&app).Error; err != nil {
		var appModified model.Applicant
		dataOld := appsession.Data
		dataSubmitted := app.Data
		// read modified applicant from db
		model.Db().Preload("Data").Preload("Data.Oblast").First(&appModified, appsession.ID)
		if appModified.ID == 0 {
			w.Header().Set("Content-Type", "application/json")
			json, _ := json.Marshal("Object was deleted")
			log.Printf("editing deleted Object, message is %s\n", string(json))
			w.Write(json)
			return
		}
		dataModified := appModified.Data
		merge, err := MergeDiff(&dataOld, &dataSubmitted, &dataModified, true, "form")
		if err != nil {
			http.Error(w, "Submission merge error: " + err.Error(), http.StatusInternalServerError)
			log.Printf("error %v, status %v\n", "Submission merge error: " + err.Error(),
				http.StatusInternalServerError)
			return
		}
		MergeScaleResults(merge, "result")
		json, err := json.Marshal(merge)
		if err != nil {
			log.Printf("Json marshalling error %v\n", err)
			http.Error(w, "Json marshalling error: " + err.Error(), http.StatusInternalServerError)
			log.Printf("error %v, status %v\n", "Json marshalling error: " + err.Error(),
				http.StatusInternalServerError)
			return
		}
		// store in session
		if err := storeApplicant(w, r, appModified, action); err != nil {
			log.Printf("Session store error %v\n", err)
			http.Error(w, "Session store error: " + err.Error(), http.StatusInternalServerError)
			log.Printf("error %v, status %v\n", "Session store error: " + err.Error(),
				http.StatusInternalServerError)
		}
		w.Header().Set("Content-Type", "application/json")
		w.Write(json)
	} else {
		w.WriteHeader(http.StatusNoContent)
	}
}
180
Das in der Session gespeicherte Applicant Objekt (V.0) holen
187 – 190
Das Objekt aus der Session kopieren und alle Formular-Werte aus dem POST Request in die Kopie übernehmen (func setApplicantData)
200
Das editierte Objekt in die Datenbank speichern:
Wenn err != nil ist, beginnt der Merge Code. Dabei wird nur das ApplicantData Objekt betrachtet, das die eigentlichen Daten enthält.
202 – 205, 213
dataOld: V.0 aus der Session,
dataSubmitted: V.1 aus der aktuellen Form Submission,
dataModified: V.x aus der Datenbank
207-209
Spezialfall, dass Objekt gelöscht wurde, z.B. als Folge einer Exmatrikulation
214
Mit diesen drei Werten wird die MergeDiff Funktion aufgerufen, die eine Datenstruktur zurückgibt in der alle voneinander abweichenden Werte und die Art der Differenz enthalten ist.
237 – 238
Wenn alles geklappt hat, wird das Merge-Resultat im JSON Format an den Browser zurückgesendet.
{
    "district": {
        "Mine": 4123,
        "Other": 4130,
        "Conflict": 2
    },
    "email": {
        "Mine": "Ronald@disney.com",
        "Other": "Donald@disney.com",
        "Conflict": 1
    },
    "firstname": {
        "Mine": "Ronald",
        "Other": "Donald",
        "Conflict": 1
    },
    "home": {
        "Mine": "Oberpfaffenhofen",
        "Other": "Ducksburgh",
        "Conflict": 2
    },
    "lastname": {
        "Mine": "Ducker",
        "Other": "Dunck",
        "Conflict": 3
    }
}
5, 20
Änderungen in der Datenbank, jemand anderes hat diesen Wert verändert – Conflict = 2
10, 15
Lokale Änderungen, aktueller Benutzer hat diesen Wert verändert – Conflict = 1
25
Änderungen in der Datenbank und lokal, Benutzer muss Änderungen manuell zusammenführen – Conflict = 3
Implementierung des 3 Weg Merge

Die Implementierung nutzt das go reflect package. Sie ist an die Anforderungen des Beispiels angepasst und kann noch verallgemeinert werden. Es werden nur einfache Datentypen und Arrays mit einfachen Datentypen unterstützt. Die Felder, die verglichen werden sollen, können ein Tag besitzen, mit dem sie zu Feldern des UI zugeordnet werden können. Wenn keine Tags verwendet werden, wird der Name des Feldes in der Go struct genommen.

Die Ergebnisse des Vergleichs werden für jedes Feld in der Datenstruktur MergeInfo abgelegt. Die Felder Mine und Other enthalten die unterschiedlichen Werte. Das Feld Conflict beschreibt die registrierte Veränderung.

type MergeDiffType int

const (
	NONE MergeDiffType = iota       // no changes at all
	MINE                            // only my value changed
	THEIRS                          // only their value changed
	BOTH                            // both values changed and are different
	SAME                            // both values changed but are equal
)

// type to record value changes for web forms
type MergeInfo struct {
	Mine     interface{}
	Other    interface{}
	Conflict MergeDiffType
}

Die Funktion MergeDiff erstellt eine map von MergeInfo Objekten. Die Methode verwendet Go Reflection, also die Möglichkeit, zur Laufzeit des Programms auf den dynamischen Typ der Parameter zuzugreifen, ihre Metaeigenschaften zu bestimmen und ihre Werte zu verändern. Um diesen Code zu verstehen sollte man sich das Package reflect und den dort empfohlenen Artikel „The Laws of Reflection“ als Einführung zu Reflection in Go näher ansehen.

// function MergeDiff runs a three way diff between the old and new versions of my struct and a
// different changed version of the same struct, similar to git merge. Differences are flagged in
// return parameter diffs using type MergeDiffType.If automerge is true, mineNew fields get
// updated to updated fields of otherNew and only changed fields are flagged in diffs.
// mineOld, mineNew, otherNew must be pointers to same struct types.
// If tag is not empty, only fields are compared that are tagged with the given tag and the tag value 
// will be used as key in the diffs map.
func MergeDiff(mineOld, mineNew, otherNew interface{}, automerge bool, tag ...string) (diffs map[string]MergeInfo, err error) {
	useTags := len(tag) == 1
	// get value objects of struct variables referenced by interfaces
	vMineOld := reflect.ValueOf(mineOld).Elem()
	vMineNew := reflect.ValueOf(mineNew).Elem()
	vOther := reflect.ValueOf(otherNew).Elem()
	diffs = make(map[string]MergeInfo)
	// make sure you don't compare apples to pears
	if vMineOld.Type() != vOther.Type() || vMineOld.Type() != vMineNew.Type() {
		err = errors.New("different types")
		return
	}
	// loop over all fields
	for i := 0; i < vMineOld.NumField(); i++ {
		fieldInfo := vMineOld.Type().Field(i)
		fieldName := fieldInfo.Name
		fieldTags := fieldInfo.Tag
		fieldMineOld := vMineOld.Field(i).Interface()
		fieldMineNew := vMineNew.Field(i).Interface()
		fieldOther := vOther.Field(i).Interface()
		// ignore anonymous fields
		if !fieldInfo.Anonymous {
			var diffkey string
			if useTags {
				diffkey = fieldTags.Get(tag[0])
				if diffkey == "" {
					continue
				}
			} else {
				diffkey = fieldName
			}
			// simple field of array/slice?
			switch fieldInfo.Type.Kind() {
			case reflect.Array:
				fallthrough
			case reflect.Slice:
				mergeArray(vMineOld.Field(i), vMineNew.Field(i), vOther.Field(i),
					diffkey, diffs, automerge)
			default:
				cmine := fieldMineNew == fieldMineOld   // has my field changed
				ctheirs := fieldOther == fieldMineOld   // has their field changed
				cdiff := fieldMineNew == fieldOther     // are new fields same
				var mergeDiff MergeDiffType
				if cdiff && cmine {
					// no change
					mergeDiff = NONE
				} else if !cdiff && cmine {
					// they changed
					mergeDiff = THEIRS
				} else if !cdiff && !cmine && ctheirs {
					// mine changed
					mergeDiff = MINE
				} else if !cdiff && !cmine && ! ctheirs {
					// both changed differently
					mergeDiff = BOTH
				} else {
					// both changed but are same
					mergeDiff = SAME
				}
				switch mergeDiff {
				case THEIRS:
					fallthrough
				case BOTH:
					diffs[diffkey] = MergeInfo{fieldMineNew, fieldOther, mergeDiff}
				case MINE:
					fallthrough
				case SAME:
					if automerge {
						diffs[diffkey] = MergeInfo{fieldMineNew, fieldOther, mergeDiff}
					}
				}
			}
		}
	}
	return
}
38 – 40
Die Parameter der Funktion sind vom Typ interface{}, also untypisiert. Da Werte verändert werden sollen, müssen sie als Pointer übergeben werden. Mit reflect.ValueOf(…).Elem()  wird das reflect.Value Objekt erhalten, auf das der Pointer zeigt. Das sind also die Value Objekte der drei structs, die verglichen werden sollen.
43
Es muss sichergestellt werden, dass Objekte des gleichen Typs verglichen werden, sonst kommt Unsinn heraus.
48
Jetzt wird über die Felder iteriert und in den nächsten 3 Zeilen etwas Metainformation gesammelt.
52 – 54
Um mit den Werten der Reflection Objekte zu arbeiten, braucht man ihr Interface(), nicht ihren Value(). Das Value Objekt beschreibt den Wert, mit Interface() greift man auf den Speicherplatz des Wertes zu und kann ihn ändern. Das erscheint zwar nicht besonders intuitiv, aber funktioniert.
71
Felder vom Array- oder Slice-Typ werden separat in mergeArray(…)  behandelt. Dort wird über das gesamte Attay oder Slice iteriert und jede Zelle analog wie hier behandelt.
74 – 76
Damit können jetzt die Werte verglichen werden für die große Fallunterscheidung.
98, 103
Die Ergebnisse des Vergleichs werden in ein neues MergeInfo Objekt geschrieben und in die diffs map eingefügt. Eigene Änderungen, die nicht in Konflikt zu Änderungen in der Datenbank stehen, werden nur in die map aufgenommen, wenn automerge auf true gesetzt ist.

Als Schlüssel für die diffs map sollten Tags verwendet werden, die den Feldnamen in der HTML Form entsprechen. Als Beispiel  die struct ApplicantData:

// all data of an applicant are stored in a separate structure in order
// to maintain a full history of changes to these sensitive data
// form: tags are used to identify html form fields and request parameters
type ApplicantData struct {
	Model
	ApplicantID    uint
	Number         uint `gorm:"AUTO_INCREMENT" form:"applid"`
	LastName       string `form:"lastname"`
	FirstName      string `form:"firstname"`
	FathersName    string `form:"fathersname"`
	LastNameTx     string `form:"lastnametx"`
	FirstNameTx    string `form:"firstnametx"`
	FathersNameTx  string `form:"fathersnametx"`
	Phone          string `form:"phone"`
	Email          string `form:"email"`
	Home           string `form:"home"`
	School         string `form:"school"`
	SchoolOk       bool `form:"schoolok"`
	Oblast         Oblast                              // Belongs To Association
	OblastID       uint `form:"district"`
	OblastOk       bool `form:"districtok"`
	OrtSum         int16 `form:"ort"`
	OrtMath        int16 `form:"ortmath"`
	OrtPhys        int16 `form:"ortphys"`
	OrtOk          bool `form:"ortok"`
	Results        [NQESTION]int `gorm:"-" form:"result#"` // marks multiplied by 10
	Resultsave     string
	LanguageResult int `form:"languageresult"`
	Language       Lang `form:"language"`
	EnrolledAt     time.Time `form:"enrolledat"`
	CancelledAt    time.Time `form:"cancelledat"`
}

Hier werden Tags der Art ´form:“feldname“´ verwendet. Eine Besonderheit ist der Tag von Results. Da dies ein Array ist, muss der Index der unterschiedlichen Werte für das Merging übergeben werden. Das ‚#‘ Zeichen im Tag wird daher in der Funktion mergeArray(…) durch den entsprechenden Index ersetzt. Dadurch kann das passende Eingabefeld in der HTML Form identifiziert werden.

Die Darstellung der Merge-Ergebnisse im Browser wird (noch nicht) auf einer anderen Seite beschrieben.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.